docs: specs, requirements, ux-flows and traceability for new features
Add specs for the account menu, downloads-as-offline-library, offline downloaded-only filter, and scoped search (+ boundary revision). Add the new UR/DR entries to requirements.md, update ux-flows, and regenerate the traceability matrix. TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
This commit is contained in:
@@ -51,14 +51,19 @@ graph TD
|
|||||||
|
|
||||||
**View Enforcement:**
|
**View Enforcement:**
|
||||||
|
|
||||||
|
Ordinal content (where position carries meaning) is always a list. Everything
|
||||||
|
else honours the user's persisted grid/list preference — see
|
||||||
|
[ux-flows.md §5A.2](../ux-flows.md).
|
||||||
|
|
||||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||||
|--------------|-----------|----------------|----------------|
|
|--------------|-----------|----------------|----------------|
|
||||||
| Tracks | List (forced) | No | `TrackList` |
|
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Artists | User preference | Yes | `LibraryGrid` |
|
||||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Albums | User preference | Yes | `LibraryGrid` |
|
||||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||||
|
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||||
|
|
||||||
**TrackList Component:**
|
**TrackList Component:**
|
||||||
|
|
||||||
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
**LibraryGrid forceGrid Prop:**
|
**LibraryGrid view mode:**
|
||||||
|
|
||||||
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||||||
|
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||||||
|
prop controls whether the toggle buttons appear in the page header; the grid
|
||||||
|
itself always follows the stored preference.
|
||||||
|
|
||||||
|
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||||||
|
preference. No caller ever passed it, so it was removed — pages that were
|
||||||
|
documented as "forced grid" have in practice always honoured the toggle.
|
||||||
|
|
||||||
## Playback Reporting Service
|
## Playback Reporting Service
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ For a narrative overview of the system design, see
|
|||||||
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
||||||
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
||||||
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
||||||
|
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
|
||||||
|
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||||
|
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||||
|
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||||
|
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
|
||||||
|
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||||
|
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||||
|
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||||
|
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,6 +106,7 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
||||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||||
|
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||||
|
|
||||||
### 2.2 Jellyfin API Requirements
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
@@ -203,6 +213,28 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
||||||
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
||||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||||
|
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||||
|
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||||
|
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||||
|
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||||
|
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||||
|
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||||
|
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||||
|
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||||
|
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||||
|
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
|
||||||
|
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||||
|
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||||
|
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||||
|
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
|
||||||
|
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
|
||||||
|
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
|
||||||
|
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||||
|
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
|
||||||
|
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
|
||||||
|
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||||
|
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||||
|
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -259,6 +291,15 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-045 | - | DR-057 |
|
| UR-045 | - | DR-057 |
|
||||||
| UR-046 | IR-028 | DR-058 |
|
| UR-046 | IR-028 | DR-058 |
|
||||||
| UR-047 | IR-013 | DR-060 |
|
| UR-047 | IR-013 | DR-060 |
|
||||||
|
| UR-048 | - | DR-061, DR-062 |
|
||||||
|
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||||
|
| UR-050 | - | DR-066, DR-067 |
|
||||||
|
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||||
|
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||||
|
| UR-053 | IR-029 | DR-074 |
|
||||||
|
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||||
|
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||||
|
| UR-056 | - | DR-085 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -329,6 +370,11 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||||
|
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||||
|
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
|
||||||
|
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
|
||||||
|
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
|
||||||
|
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
@@ -347,6 +393,8 @@ Internal architecture, components, and application logic.
|
|||||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||||
|
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
|
||||||
|
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Pending |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Spec review checklist
|
||||||
|
|
||||||
|
Run a spec past this before accepting it. It exists because JellyTau's
|
||||||
|
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
||||||
|
rule lived in the architecture docs, but nothing forced a spec author to check a
|
||||||
|
new design against it, and a "minimal-change" spec quietly leaked domain
|
||||||
|
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
||||||
|
This checklist is the human gate. The CI check
|
||||||
|
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
||||||
|
signature — it does **not** replace this.
|
||||||
|
|
||||||
|
Copy the boxes into the review comment (or the PR) and tick them.
|
||||||
|
|
||||||
|
## Boundary (the one that bites)
|
||||||
|
|
||||||
|
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
||||||
|
*logic*, not files. A spec without this section is not ready to review.
|
||||||
|
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
||||||
|
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
||||||
|
query-shaping rules, business rules, reachability/sync policy. If the
|
||||||
|
frontend names a *set* of item types to define a category, that is a leak —
|
||||||
|
it belongs behind an opaque enum the backend expands.
|
||||||
|
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
||||||
|
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
||||||
|
the decision of its value.
|
||||||
|
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
||||||
|
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
||||||
|
The goal is *correct layer placement*; sometimes that is more Rust work.
|
||||||
|
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
||||||
|
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
||||||
|
frontend. Borderline defaults to Rust.
|
||||||
|
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
||||||
|
is **not** over-corrected into the backend. The rule targets category
|
||||||
|
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
||||||
|
list page.
|
||||||
|
|
||||||
|
## IPC contract
|
||||||
|
|
||||||
|
- [ ] Anything crossing the boundary has its wire shape specified.
|
||||||
|
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
||||||
|
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
||||||
|
sides; events are kebab-case. (CLAUDE.md §IPC,
|
||||||
|
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
||||||
|
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
||||||
|
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
||||||
|
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
||||||
|
|
||||||
|
## Requirements & traceability
|
||||||
|
|
||||||
|
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||||
|
[requirements.md](../requirements.md).
|
||||||
|
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||||
|
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||||
|
|
||||||
|
## Conflicts & hygiene
|
||||||
|
|
||||||
|
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
||||||
|
pointing here — no two specs silently contradicting.
|
||||||
|
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
||||||
|
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
||||||
|
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
||||||
|
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
||||||
|
assignment first. Every other section can be negotiated; that one is the whole
|
||||||
|
reason this file exists.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Spec: <feature name>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
||||||
|
comments as you go. The section that matters most for this project is
|
||||||
|
"Layer assignment" — read its comment before writing it.
|
||||||
|
|
||||||
|
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
|
||||||
|
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
|
||||||
|
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
|
||||||
|
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
<!-- Why now. The problem being solved. -->
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
<!--
|
||||||
|
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
||||||
|
do NOT reframe it as "how little backend work can we get away with."
|
||||||
|
|
||||||
|
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
||||||
|
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
||||||
|
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
||||||
|
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
||||||
|
navigation, view/order preferences, input handling.
|
||||||
|
|
||||||
|
For each distinct piece of *logic* this feature introduces, put it in the table
|
||||||
|
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
||||||
|
"the backend already accepts this parameter" are NOT reasons to place logic in
|
||||||
|
the frontend — the backend accepting a parameter does not make deciding that
|
||||||
|
parameter's value a presentation concern.
|
||||||
|
|
||||||
|
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
||||||
|
Jellyfin changed its API, added an item type, or altered a business rule? If
|
||||||
|
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
||||||
|
yes (and only yes), it is presentation → frontend.
|
||||||
|
|
||||||
|
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
||||||
|
means as a set of Jellyfin types) in the frontend because the backend already
|
||||||
|
accepted an includeItemTypes filter. That was a boundary leak; see
|
||||||
|
scoped-search-boundary.md. This section exists to catch that class of mistake
|
||||||
|
at spec time, not in review three features later.
|
||||||
|
-->
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
||||||
|
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
||||||
|
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
<!--
|
||||||
|
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
||||||
|
- Command NAME must match the Rust fn name exactly.
|
||||||
|
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
||||||
|
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
||||||
|
- Events are kebab-case.
|
||||||
|
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
||||||
|
|
||||||
|
Regenerate bindings.ts from Rust types; never hand-edit it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
<!-- What this spec deliberately does NOT do. -->
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
<!-- Checkable statements. Include the standard gates: -->
|
||||||
|
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
||||||
|
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
||||||
|
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||||
|
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
<!--
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
||||||
|
- Anything else non-obvious.
|
||||||
|
-->
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Spec: Account menu and global chrome availability
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Scope:** Frontend only. No Rust changes required.
|
||||||
|
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
|
||||||
|
[requirements.md](../requirements.md)).
|
||||||
|
**UX spec:** [ux-flows.md §1.2–1.4](../ux-flows.md).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Account actions — Settings, Downloads, Display preferences, Sign out — are
|
||||||
|
currently reachable **only from `/library/*`**. Move them into a single shared
|
||||||
|
account menu anchored to the user's name, and make that menu available on every
|
||||||
|
authenticated non-immersive screen.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A user sitting on the home screen cannot open Settings or sign out. The bottom
|
||||||
|
nav offers Home / Search / Library only, and the header that hosts those actions
|
||||||
|
belongs to the library layout. The user has to guess that account actions live
|
||||||
|
*inside* Library — an unrelated section — and navigate there first.
|
||||||
|
|
||||||
|
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
|
||||||
|
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
|
||||||
|
same two actions are found two different ways.
|
||||||
|
|
||||||
|
## Background: verified current state
|
||||||
|
|
||||||
|
1. **The header is not global.** It is defined in
|
||||||
|
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
|
||||||
|
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
|
||||||
|
all.
|
||||||
|
|
||||||
|
2. **`routeOwnsLayout`** in
|
||||||
|
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
|
||||||
|
`/library`, `/player/`, `/login` — those routes own their own full-height
|
||||||
|
flex column. Everything else renders into the root scroller with the root's
|
||||||
|
`BottomUi` below it.
|
||||||
|
|
||||||
|
3. **Bottom nav is Home / Search / Library only**
|
||||||
|
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
|
||||||
|
or account entry.
|
||||||
|
|
||||||
|
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
|
||||||
|
Settings or Sign out.
|
||||||
|
|
||||||
|
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
|
||||||
|
trigger.
|
||||||
|
|
||||||
|
6. **The mobile overflow menu already has the right contents** (Downloads,
|
||||||
|
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
|
||||||
|
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
|
||||||
|
|
||||||
|
7. **`viewMode` is already a persisted store** in
|
||||||
|
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
|
||||||
|
`localStorage`). The Display setting is a second view onto it — **no new
|
||||||
|
state, no migration.**
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `AccountMenu` component (DR-075)
|
||||||
|
|
||||||
|
One component used by both breakpoints. Contents in fixed order:
|
||||||
|
|
||||||
|
```
|
||||||
|
Signed in as <name> ← identity block, not interactive
|
||||||
|
<server host>
|
||||||
|
────────────────────────
|
||||||
|
Downloads
|
||||||
|
Settings
|
||||||
|
Display ← grid/list preference
|
||||||
|
────────────────────────
|
||||||
|
Sign out ← destructive, last, after a divider
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
|
||||||
|
horizontal space is tight, the avatar (or initial) alone is acceptable; the
|
||||||
|
name shows inside the open menu regardless.
|
||||||
|
- **Same items, same order, both platforms.**
|
||||||
|
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
|
||||||
|
and focus return to the trigger on close.
|
||||||
|
- Menu items are real links/buttons — keyboard reachable, correct roles,
|
||||||
|
`aria-expanded` on the trigger.
|
||||||
|
|
||||||
|
"Display" may either navigate to the Settings Display section or expose the
|
||||||
|
grid/list choice inline. Prefer navigating — it keeps one source of truth for
|
||||||
|
preferences and avoids a nested control inside a dropdown.
|
||||||
|
|
||||||
|
### Global chrome (DR-076)
|
||||||
|
|
||||||
|
Make the header — and therefore the account menu — available on `/`, `/search`,
|
||||||
|
and `/downloads`.
|
||||||
|
|
||||||
|
The cleanest route is to lift the header out of the library layout into a shared
|
||||||
|
component rendered by the root layout, with the library layout consuming the
|
||||||
|
same component rather than defining its own. **Do not duplicate the markup into
|
||||||
|
each route.**
|
||||||
|
|
||||||
|
Constraints that must survive the change:
|
||||||
|
|
||||||
|
- `/player/*` and `/login` stay chrome-free.
|
||||||
|
- `/settings` already owns its layout; it needs no account menu (the user is
|
||||||
|
already there), but must not double up on chrome.
|
||||||
|
- The root layout's flex/scroller structure is deliberate — the comments in
|
||||||
|
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
|
||||||
|
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
|
||||||
|
own column. Preserve the scroll containment; a regression here reintroduces
|
||||||
|
the "last row hidden behind the nav" bug called out in those comments.
|
||||||
|
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
|
||||||
|
`showBottomNav`) must be unchanged.
|
||||||
|
|
||||||
|
### Display section in Settings (DR-077)
|
||||||
|
|
||||||
|
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
|
||||||
|
with the grid/list control bound to the existing `viewMode` store via
|
||||||
|
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
|
||||||
|
controls drive the same store, so they stay in sync for free.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Redesigning the Settings page or reorganising its existing sections.
|
||||||
|
- Multi-server / account switching (UR-047) — the identity block displays the
|
||||||
|
active server but offers no switcher.
|
||||||
|
- Changing the bottom nav's three destinations.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
|
||||||
|
without first navigating into Library.
|
||||||
|
- [ ] Desktop and mobile show the same account menu items in the same order.
|
||||||
|
- [ ] The username/avatar opens the menu; it is a real button with
|
||||||
|
`aria-expanded`.
|
||||||
|
- [ ] Sign out is last, after a divider, and still logs out + resets library
|
||||||
|
state + redirects as it does today.
|
||||||
|
- [ ] `/player/*` and `/login` remain chrome-free.
|
||||||
|
- [ ] Settings has a Display section that changes grid/list, and the change is
|
||||||
|
immediately reflected by the library page-header toggle (same store).
|
||||||
|
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
|
||||||
|
visibility on any route.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
|
||||||
|
`/downloads` (now true) and `/player/*`, `/login` (still false).
|
||||||
|
- `AccountMenu`: renders the documented items in order; trigger toggles
|
||||||
|
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
|
||||||
|
logout handler.
|
||||||
|
- Display setting: writes through to the `viewMode` store and persists.
|
||||||
|
|
||||||
|
New requirement-implementing code needs `TRACES:` comments — see
|
||||||
|
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu` → `UR-054 | DR-075`,
|
||||||
|
shell/header changes → `UR-054 | DR-076`, Settings Display section →
|
||||||
|
`UR-054, UR-029 | DR-077`.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- Read [ux-flows.md §1.2–1.4](../ux-flows.md) first — behavioural spec; this is
|
||||||
|
the implementation plan.
|
||||||
|
- The layout shell is subtle and the existing comments record real bugs that
|
||||||
|
were fixed there. Read them before restructuring.
|
||||||
|
- Another session may be active in this repo, including in
|
||||||
|
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
|
||||||
|
unexpected changes, and expect to coordinate on that file.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Spec: Downloads as a browsable offline library
|
||||||
|
|
||||||
|
**Status:** Draft — ready to implement
|
||||||
|
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
|
||||||
|
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
|
||||||
|
(see [requirements.md](../requirements.md)).
|
||||||
|
**UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the flat Active/Completed download list with two views under
|
||||||
|
`/downloads`:
|
||||||
|
|
||||||
|
1. **Downloaded** (default) — the library, filtered to what's on the device,
|
||||||
|
using the *same* browse screens as online (grids, cards, detail pages).
|
||||||
|
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
|
||||||
|
showing only in-flight transfers.
|
||||||
|
|
||||||
|
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
|
||||||
|
pages, a device total, and the remove confirmation.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A user who downloaded three seasons and two albums sees ~70 individual transfer
|
||||||
|
rows today, with no grouping and no reuse of the library UI. "What do I have
|
||||||
|
offline" and "what is downloading" are different questions crammed into one flat
|
||||||
|
list. Browsing offline should feel exactly like browsing online.
|
||||||
|
|
||||||
|
## Background: verified current state
|
||||||
|
|
||||||
|
1. **The offline repository is already a browsable tree.**
|
||||||
|
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
|
||||||
|
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
|
||||||
|
least one downloaded child. `get_libraries`, `get_item`, and `search` all
|
||||||
|
filter to downloaded content via CTEs. This is the data source for
|
||||||
|
Downloaded; **do not build a new query layer.**
|
||||||
|
|
||||||
|
2. **The client cannot reach it independently.**
|
||||||
|
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` →
|
||||||
|
`repositoryGetItems` always goes through the **hybrid** repository
|
||||||
|
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
|
||||||
|
server. There is no "offline only" browse path exposed. This is the one real
|
||||||
|
backend gap (DR-082).
|
||||||
|
|
||||||
|
3. **Downloads page is a flat two-tab list.**
|
||||||
|
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
|
||||||
|
Completed tabs, one `DownloadItem` row per transfer, no browsing.
|
||||||
|
|
||||||
|
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
|
||||||
|
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
|
||||||
|
given. Downloaded browse is those components with an offline-scoped source.
|
||||||
|
|
||||||
|
5. **A related fallthrough bug is already tracked** (DR-080, another session):
|
||||||
|
`HybridRepository::get_items` treats an empty offline result as a cache miss
|
||||||
|
and falls through to the server. The offline-only browse path (DR-082) must
|
||||||
|
**not** share that behaviour — an empty result there is authoritative "nothing
|
||||||
|
downloaded here."
|
||||||
|
|
||||||
|
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
|
||||||
|
Do not surface them as manual controls; do not loop `startDownload` from the
|
||||||
|
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### View split (DR-081)
|
||||||
|
|
||||||
|
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
|
||||||
|
default. Transfers shows a count/badge only while transfers are active.
|
||||||
|
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
|
||||||
|
does not start downloads.
|
||||||
|
|
||||||
|
### Offline-scoped browse source (DR-082, DR-083)
|
||||||
|
|
||||||
|
Add an explicit offline-only browse path so Downloaded never merges server
|
||||||
|
results and never depends on reachability. Two viable shapes — pick per the
|
||||||
|
codebase, do not do both:
|
||||||
|
|
||||||
|
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
|
||||||
|
`_libraries`) that calls the offline repository directly, with a matching
|
||||||
|
client method; or
|
||||||
|
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
|
||||||
|
that bypasses the hybrid merge and the empty→fallthrough behaviour.
|
||||||
|
|
||||||
|
Either way: an empty result is authoritative (do **not** reuse the DR-080
|
||||||
|
fallthrough), and the path is available while the server is reachable (a user
|
||||||
|
online still wants to browse their downloads).
|
||||||
|
|
||||||
|
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
|
||||||
|
source. Omit libraries and containers with no downloaded content. Badge
|
||||||
|
partially- vs fully-downloaded containers. Play uses the local file; remove is
|
||||||
|
available at item / album / season / series level and removes a container from
|
||||||
|
the browse when its last downloaded child goes.
|
||||||
|
|
||||||
|
### Transfers view (DR-084)
|
||||||
|
|
||||||
|
The existing list, filtered to in-flight rows only: downloading (with progress),
|
||||||
|
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
|
||||||
|
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
|
||||||
|
this view — they appear in Downloaded. Empty state points at the library.
|
||||||
|
|
||||||
|
### Disk usage (DR-085, UR-056)
|
||||||
|
|
||||||
|
- **Source the bytes from the download manager** — it writes the files and can
|
||||||
|
stat them. Aggregate to album/season/series subtotals and a device total.
|
||||||
|
This is display + aggregation, **not** new tracking.
|
||||||
|
- **Format once, consistently.** One shared formatter, human units, 2–3
|
||||||
|
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
|
||||||
|
it everywhere.
|
||||||
|
- **Surface it in familiar places:** a secondary size label on the card and
|
||||||
|
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
|
||||||
|
that reconciles with the listed sum; a reclaim figure in the remove
|
||||||
|
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
|
||||||
|
- Sort/filter by size is a nice-to-have, not required for v1.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
|
||||||
|
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
|
||||||
|
that governs the *online offline-fallback* library; this is the dedicated
|
||||||
|
Downloads surface. They should be consistent but are separate work.
|
||||||
|
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
|
||||||
|
on that behaviour here.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
|
||||||
|
- [ ] Downloaded lists only libraries/containers with downloaded content, using
|
||||||
|
the same grids/cards/detail pages as online browsing.
|
||||||
|
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
|
||||||
|
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
|
||||||
|
through to the server.
|
||||||
|
- [ ] Play from Downloaded plays the local file.
|
||||||
|
- [ ] Remove works at item/album/season/series level and updates the browse.
|
||||||
|
- [ ] Transfers shows only in-flight rows with working controls; finished
|
||||||
|
transfers move to Downloaded.
|
||||||
|
- [ ] Each downloaded item/container shows its on-disk size; a device total is
|
||||||
|
shown and reconciles with the sum; remove states the reclaim amount.
|
||||||
|
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
|
||||||
|
`cargo clippy` pass.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Repository client: the offline-only browse path returns downloaded content and
|
||||||
|
its containers, and an empty result does **not** trigger server fallthrough.
|
||||||
|
- Downloaded view: libraries/containers with no downloads are omitted;
|
||||||
|
partial/full container badging.
|
||||||
|
- Transfers: only in-flight statuses render; a completed transfer disappears.
|
||||||
|
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
|
||||||
|
total reconciles with listed items.
|
||||||
|
- If a Rust command is added, add the tauri IPC param-naming coverage per
|
||||||
|
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
|
||||||
|
|
||||||
|
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
|
||||||
|
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
|
||||||
|
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is
|
||||||
|
the implementation plan.
|
||||||
|
- The offline repository already does the hard part. The main work is a clean
|
||||||
|
offline-only client path and reusing the library components — resist
|
||||||
|
rebuilding browse UI.
|
||||||
|
- Another session is active in downloads/offline/connectivity code (DR-074,
|
||||||
|
DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
|
||||||
|
and the repository layer; check `git diff` before repairing unexpected changes.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Spec: Offline "downloaded only" filtering (issue #10)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
|
||||||
|
commands, no schema changes, no UI additions.
|
||||||
|
**Requirements:** UR-052 → DR-078, DR-079, DR-080
|
||||||
|
(see [requirements.md](../requirements.md)).
|
||||||
|
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
|
||||||
|
media does not work."*
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Offline, a library page is supposed to show **only media on the device**, with a
|
||||||
|
"Show all server media" toggle that additionally reveals the cached server
|
||||||
|
catalog greyed out (queueable for download on reconnect). In practice the toggle
|
||||||
|
does not gate the listing — every server item still appears. This spec fixes
|
||||||
|
that with two independent changes; either one alone leaves the bug visible.
|
||||||
|
|
||||||
|
## Background: what already exists
|
||||||
|
|
||||||
|
Verified in code. **The feature is built and mostly correct — this is a
|
||||||
|
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
|
||||||
|
command, or the SQL gate.
|
||||||
|
|
||||||
|
1. **The SQL gate works and is unit-tested.**
|
||||||
|
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
|
||||||
|
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
|
||||||
|
true; with it false, only downloaded/local rows return. Guarded by
|
||||||
|
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
|
||||||
|
query.**
|
||||||
|
|
||||||
|
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
|
||||||
|
`set_show_server_catalog` command
|
||||||
|
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
|
||||||
|
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
|
||||||
|
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
|
||||||
|
`include = connected || showCatalog` and pushes it on every change.
|
||||||
|
|
||||||
|
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
|
||||||
|
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
|
||||||
|
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
|
||||||
|
them.
|
||||||
|
|
||||||
|
4. **`MediaCard` already greys and queues.**
|
||||||
|
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
|
||||||
|
`isServerOnly` renders the greyed, inert card with a queue button; the queued
|
||||||
|
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
|
||||||
|
it.
|
||||||
|
|
||||||
|
## The two defects
|
||||||
|
|
||||||
|
### Defect A — offline is never actually entered (DR-079)
|
||||||
|
|
||||||
|
`pushCatalogVisibility` keys off `isConnected`, but
|
||||||
|
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
|
||||||
|
```
|
||||||
|
|
||||||
|
`navigator.onLine` is documented in that same file as **advisory only** — the
|
||||||
|
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
|
||||||
|
real traffic*, DR-055). When the server is unreachable but the device link is
|
||||||
|
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
|
||||||
|
stays true, so `include` stays true, so the backend keeps returning the full
|
||||||
|
catalog. The user is "offline" in every meaningful sense but the toggle never
|
||||||
|
gets a chance to gate anything.
|
||||||
|
|
||||||
|
This is the primary cause: it explains why the filter looks dead rather than
|
||||||
|
merely inverted — the gate never closes.
|
||||||
|
|
||||||
|
### Defect B — an intentionally empty result falls through to the server (DR-080)
|
||||||
|
|
||||||
|
With the gate off and nothing downloaded in a library, offline `get_items`
|
||||||
|
correctly returns few or zero rows. But
|
||||||
|
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
|
||||||
|
hit only `if data.has_content()`. An empty offline result is indistinguishable
|
||||||
|
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
|
||||||
|
~10 other reads) falls through to the server and returns the full server list —
|
||||||
|
re-defeating the filter even after Defect A is fixed.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Fix A: `isConnected` follows backend reachability alone (DR-079)
|
||||||
|
|
||||||
|
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
|
||||||
|
store:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const isConnected = derived(
|
||||||
|
connectivity,
|
||||||
|
($c) => $c.isServerReachable
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
|
||||||
|
immediate recheck (`online`/`offline` listeners already call
|
||||||
|
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
|
||||||
|
Leave `isOnline` on the state object and the listeners intact.
|
||||||
|
|
||||||
|
Consider whether the optimistic `isServerReachable: true` startup default
|
||||||
|
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
|
||||||
|
first real check resolves. Keep it — flipping the app to "offline" on launch is a
|
||||||
|
worse regression than a brief full-catalog flash before the first probe. Note the
|
||||||
|
choice in a comment.
|
||||||
|
|
||||||
|
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
|
||||||
|
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
|
||||||
|
anything importing it). Enumerate consumers first:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -rn "isConnected" src/ | grep -v node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
For each, confirm "server unreachable" (not "device link down") is the correct
|
||||||
|
trigger. It almost always is — that is the whole point of the reachability model
|
||||||
|
— but verify rather than assume, and call out anything that genuinely wanted the
|
||||||
|
device link in the PR description.
|
||||||
|
|
||||||
|
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
|
||||||
|
|
||||||
|
The backend must distinguish "cache is cold, go ask the server" from "user asked
|
||||||
|
for downloads only and there are none here." The gate flag already encodes intent
|
||||||
|
— reuse it.
|
||||||
|
|
||||||
|
Add a getter beside the existing setter in
|
||||||
|
[offline.rs](../../src-tauri/src/repository/offline.rs):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
|
||||||
|
`!include_catalog_browse()`, treat the offline result as authoritative and return
|
||||||
|
it **as-is even when empty** — do not spawn/await the server fallback for this
|
||||||
|
call. When the flag is on (online fast-path, or offline with the toggle on),
|
||||||
|
behaviour is unchanged: empty cache still falls through to the server.
|
||||||
|
|
||||||
|
Keep it surgical:
|
||||||
|
|
||||||
|
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
|
||||||
|
thread it into `parallel_race` or the other readers, which have no catalog
|
||||||
|
gate and legitimately want the server on an empty cache.
|
||||||
|
- Preserve the online path exactly: with the flag on (its default, and always so
|
||||||
|
while reachable) the method behaves as it does today, including the background
|
||||||
|
cache refresh on a hit.
|
||||||
|
- The flag is process-global `Relaxed`; it is set from the frontend before the
|
||||||
|
query. That ordering already holds for the SQL gate — no new synchronization.
|
||||||
|
|
||||||
|
### Why both
|
||||||
|
|
||||||
|
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
|
||||||
|
downloads present the list still gets padded by the server fallback whenever a
|
||||||
|
library's cache is thin. B alone: the gate never closes because `isConnected`
|
||||||
|
never goes false on a live link. Ship them together.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
|
||||||
|
- `MediaCard` greying / queue-on-reconnect — correct.
|
||||||
|
- Home-screen and resume queries — already downloads-only.
|
||||||
|
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
|
||||||
|
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
|
||||||
|
- Any new IPC command, DB column, or settings entry.
|
||||||
|
- Making the "Show all server media" toggle reachable from Settings (that is a
|
||||||
|
UX-placement question, tracked separately under UR-051's toggle note).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [~] With the server unreachable on a live device link, a library page lists
|
||||||
|
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
|
||||||
|
coverage via UT-069 + gate tests).
|
||||||
|
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
|
||||||
|
hides it again — without leaving/re-entering the page (SQL gate + toggle
|
||||||
|
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
|
||||||
|
- [x] A library with downloads and a thin cache does not get padded with
|
||||||
|
non-downloaded server items when offline with the toggle off (Defect B —
|
||||||
|
UT-070: gate off + empty offline result returned as-is, server not queried).
|
||||||
|
- [x] `isConnected` is false whenever the server is unreachable, regardless of
|
||||||
|
`navigator.onLine`; true for a reachable server even if the browser reports
|
||||||
|
offline (UT-069).
|
||||||
|
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
|
||||||
|
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
|
||||||
|
"server unreachable", which is the new semantics; `CastButton`'s local
|
||||||
|
`isConnected` is unrelated). Full frontend suite (616 tests) green.
|
||||||
|
- [x] Online behaviour is unchanged: with the flag on (its default, always so
|
||||||
|
while reachable) `get_items` keeps the offline fast-path and background
|
||||||
|
refresh (UT-067 + gate-on fall-through test).
|
||||||
|
- [~] A download queued from a greyed offline card resolves and starts on
|
||||||
|
reconnect (IT-017 — regression check, no code change; offlineCatalog
|
||||||
|
resume path untouched).
|
||||||
|
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
|
||||||
|
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
|
||||||
|
touched files).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
|
||||||
|
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
|
||||||
|
|
||||||
|
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
|
||||||
|
result as-is and does **not** query the server. Assert via a mock online repo
|
||||||
|
whose `get_items` bumps a call counter that must stay at zero.
|
||||||
|
- Gate on + empty cache still falls through to the server (guard the online path).
|
||||||
|
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
|
||||||
|
|
||||||
|
Frontend (vitest, `src/lib/**/*.test.ts`):
|
||||||
|
|
||||||
|
- **UT-069** — `isConnected` follows `isServerReachable` alone: false when
|
||||||
|
unreachable with `navigator.onLine === true`; true when reachable with
|
||||||
|
`navigator.onLine === false`.
|
||||||
|
- **UT-068** — `pushCatalogVisibility` resolves `serverReachable || showCatalog`
|
||||||
|
and pushes to the backend on a change of either input (extend the existing
|
||||||
|
offlineCatalog tests).
|
||||||
|
|
||||||
|
Integration (IT-016, IT-017) are documented as pending in
|
||||||
|
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
|
||||||
|
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
|
||||||
|
|
||||||
|
New/changed requirement code keeps its `TRACES:` comments — see
|
||||||
|
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
|
||||||
|
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
|
||||||
|
(`… | DR-078`). Update the getter's tag when you expose it.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
|
||||||
|
before Fix A — it is the canonical statement of the reachability model this fix
|
||||||
|
restores fidelity to.
|
||||||
|
- Fix B relies on the frontend having pushed the flag before the query runs; that
|
||||||
|
ordering already holds for the SQL gate today. No new locking.
|
||||||
|
- Another session is active in this repo (WiFi-only downloads, account menu
|
||||||
|
landed alongside this work). Check `git diff` before "repairing" unexpected
|
||||||
|
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
|
||||||
|
other new rows.
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# Spec: Move search scope taxonomy behind the Rust boundary
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Scope:** Rust + Frontend. **Revises a decision in
|
||||||
|
[scoped-search.md](scoped-search.md).**
|
||||||
|
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
||||||
|
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
||||||
|
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
||||||
|
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
||||||
|
architecture/boundary change with **no user-visible behaviour difference**.
|
||||||
|
|
||||||
|
## Why this spec exists
|
||||||
|
|
||||||
|
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
||||||
|
Rust changes." That was the smallest wiring change, and it worked — but it left
|
||||||
|
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
||||||
|
violates the project's core boundary rule ("Svelte frontend — presentation
|
||||||
|
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
||||||
|
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
||||||
|
|
||||||
|
The offending knowledge lives in
|
||||||
|
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const SCOPE_ITEM_TYPES = {
|
||||||
|
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||||
|
movies: ["Movie"],
|
||||||
|
tv: ["Series", "Episode"],
|
||||||
|
};
|
||||||
|
const GROUP_ITEM_TYPES = {
|
||||||
|
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
||||||
|
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
||||||
|
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
||||||
|
creates: the day the backend starts returning a type the frontend never
|
||||||
|
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
||||||
|
drops it from both the query filter and the result buckets, and nothing in the
|
||||||
|
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
||||||
|
sources of truth that will drift.
|
||||||
|
|
||||||
|
**This must be fixed while the feature is uncommitted**, before the leak ships
|
||||||
|
baked into a released wire contract.
|
||||||
|
|
||||||
|
### What is *not* a leak (leave it alone)
|
||||||
|
|
||||||
|
Single concrete-type list pages are **not** business logic and stay as-is:
|
||||||
|
|
||||||
|
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
||||||
|
`tv.ts` → `["Series"]`
|
||||||
|
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
||||||
|
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
||||||
|
`PersonDetailView`
|
||||||
|
|
||||||
|
"This page shows albums" is a legitimate presentation choice expressed through a
|
||||||
|
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
||||||
|
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
||||||
|
the line. Do **not** invent a backend enum for every list page — that is
|
||||||
|
over-abstraction, not cleaner separation.
|
||||||
|
|
||||||
|
## The boundary rule after this change
|
||||||
|
|
||||||
|
> The frontend never names a Jellyfin item type **in connection with search.**
|
||||||
|
> It sends an opaque `scope`, and receives results already sorted into labelled
|
||||||
|
> groups. The frontend owns only **group order** (presentation) and
|
||||||
|
> **rendering**.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Rust owns scope → item-types (query side)
|
||||||
|
|
||||||
|
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// repository/types.rs
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SearchScope { All, Music, Movies, Tv }
|
||||||
|
|
||||||
|
impl SearchScope {
|
||||||
|
/// The Jellyfin item types this scope requests, or None for `All`
|
||||||
|
/// (which must send NO includeItemTypes — see below).
|
||||||
|
pub fn item_types(self) -> Option<Vec<String>> {
|
||||||
|
match self {
|
||||||
|
SearchScope::All => None,
|
||||||
|
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||||
|
.into_iter().map(String::from).collect()),
|
||||||
|
SearchScope::Movies => Some(vec!["Movie".into()]),
|
||||||
|
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SearchOptions` gains `scope` and the search command resolves it into the
|
||||||
|
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
||||||
|
online/offline paths (which already honour `include_item_types` — do not touch
|
||||||
|
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SearchOptions {
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
pub search_term: Option<String>,
|
||||||
|
pub scope: Option<SearchScope>, // NEW
|
||||||
|
// include_item_types stays for the single-type list-page callers,
|
||||||
|
// but the SEARCH command derives it from `scope` when scope is set.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
||||||
|
non-search `getItems` callers. Document this so a future reader does not send
|
||||||
|
both.
|
||||||
|
|
||||||
|
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
||||||
|
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
||||||
|
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
||||||
|
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
||||||
|
|
||||||
|
### Rust owns result bucketing (result side)
|
||||||
|
|
||||||
|
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
||||||
|
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
||||||
|
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
||||||
|
|
||||||
|
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust emits **every** non-empty group it can classify, in a stable canonical
|
||||||
|
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
||||||
|
those are presentation and stay frontend-side (see below). Items whose type maps
|
||||||
|
to no group are omitted from grouped output (same as today's frontend filter).
|
||||||
|
|
||||||
|
### 🔴 The `search-event` wrinkle — both payloads must change
|
||||||
|
|
||||||
|
Search returns results **twice**: the command resolves with instant local-cache
|
||||||
|
results, then the merged cache+server union arrives later via the `search-event`
|
||||||
|
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
||||||
|
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
||||||
|
command return value **and** the `search-event` payload must carry
|
||||||
|
`GroupedSearchResult`. If only one is converted, the instant results group and
|
||||||
|
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
||||||
|
is the single largest part of the change and the easiest to half-do.
|
||||||
|
|
||||||
|
### What the frontend keeps (all pure presentation)
|
||||||
|
|
||||||
|
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
||||||
|
|
||||||
|
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
||||||
|
Rust enum (delete the hand-written union).
|
||||||
|
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
||||||
|
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
||||||
|
unit-tested. **Stays exactly as-is.**
|
||||||
|
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
||||||
|
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
||||||
|
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
||||||
|
presentation.
|
||||||
|
|
||||||
|
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
||||||
|
|
||||||
|
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
||||||
|
- `scopeItemTypes()`, `groupItemTypes()`.
|
||||||
|
- The `.type`-inspecting body of `composeSearchGroups()`.
|
||||||
|
|
||||||
|
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
||||||
|
groups** — no `.type` inspection anywhere:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
||||||
|
// attach labels, omit empties. No Jellyfin type vocabulary.
|
||||||
|
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
||||||
|
```
|
||||||
|
|
||||||
|
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
||||||
|
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
||||||
|
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
||||||
|
drop out via the empty-omit rule — so the frontend does not strictly need
|
||||||
|
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
||||||
|
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
||||||
|
it as a display hint, not authority.
|
||||||
|
|
||||||
|
### Frontend call-site changes
|
||||||
|
|
||||||
|
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
||||||
|
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
||||||
|
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
||||||
|
event merge) is preserved.
|
||||||
|
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||||
|
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
||||||
|
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
||||||
|
results.
|
||||||
|
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
||||||
|
behaviour; only the type it passes to `SearchResults` changes.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Any change to online/offline `include_item_types` **filtering** — it already
|
||||||
|
works; only the *source* of the type list moves.
|
||||||
|
- Single concrete-type list pages (see "What is not a leak").
|
||||||
|
- Ranking within or across groups.
|
||||||
|
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
||||||
|
[scoped-search.md](scoped-search.md).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
||||||
|
in `searchScope.ts` or any search call path. Verify:
|
||||||
|
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
||||||
|
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
||||||
|
`bindings.ts`, not hand-written unions.
|
||||||
|
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
||||||
|
groups, same order, same empty/out-of-scope omission, offline included.
|
||||||
|
- [ ] Both the command return and the `search-event` payload carry the grouped
|
||||||
|
shape; no shape flicker between instant and merged results.
|
||||||
|
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
||||||
|
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
||||||
|
committed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Rust** (`src-tauri`, `cargo test`):
|
||||||
|
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
||||||
|
- Search command: `scope: Music` resolves to the four music types on the query;
|
||||||
|
`scope: All` sends no `include_item_types`.
|
||||||
|
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
||||||
|
unknown types are dropped; groups come out in canonical order.
|
||||||
|
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
||||||
|
|
||||||
|
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
||||||
|
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
||||||
|
outgoing options — **rewrite** to assert `scope` is sent instead.
|
||||||
|
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
||||||
|
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
||||||
|
compose-over-groups (order + empty-omit, no type inspection).
|
||||||
|
- `searchGroupOrder.test.ts` — unchanged.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
||||||
|
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
||||||
|
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
||||||
|
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
||||||
|
the wire shape.
|
||||||
|
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
||||||
|
§Design "Scope model / Threading scope through the store," which asserted no
|
||||||
|
Rust change. Update that spec's status to note the boundary was moved, or add a
|
||||||
|
banner pointing here — do not leave the two specs contradicting silently.
|
||||||
|
- The IPC camelCase rule applies to the new enums and structs
|
||||||
|
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
||||||
|
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
||||||
|
`tauriIntegration`-style test if a new command is introduced.
|
||||||
|
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
||||||
|
types; do not hand-edit it.
|
||||||
|
- **Another Claude session may be active in these same files** (per project
|
||||||
|
memory). `git diff` before repairing anything unexpected; these search files
|
||||||
|
are exactly the ones a parallel session touched.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Spec: Context-scoped search with filter chips and configurable group order
|
||||||
|
|
||||||
|
> ⚠️ **Superseded in part by
|
||||||
|
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
||||||
|
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
||||||
|
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
||||||
|
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
||||||
|
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||||
|
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||||
|
> changes. Read the boundary spec before touching search code.
|
||||||
|
|
||||||
|
**Status:** Implemented (boundary revision pending — see banner above)
|
||||||
|
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||||
|
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||||
|
(see [requirements.md](../requirements.md)).
|
||||||
|
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
||||||
|
§6.3 group order, §6.4 current deviations.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Two related changes to search:
|
||||||
|
|
||||||
|
1. **Scope** — a search started inside a library searches *that* library.
|
||||||
|
Started from Home, `/library`, or the search tab, it searches everything.
|
||||||
|
The active scope shows as a chip row under the search bar, preselected from
|
||||||
|
context and freely changeable without retyping.
|
||||||
|
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
||||||
|
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Searching "office" while browsing TV currently returns music albums, because
|
||||||
|
both search entry points call the same unscoped query. The user has already
|
||||||
|
told us what they're looking at; ignoring that makes search feel indiscriminate
|
||||||
|
and pushes the relevant result below unrelated media.
|
||||||
|
|
||||||
|
## Background: what already exists
|
||||||
|
|
||||||
|
Verified in code — **most of the plumbing is already there.** This is
|
||||||
|
substantially a wiring task, not new infrastructure.
|
||||||
|
|
||||||
|
1. **`SearchOptions` already carries the filter.**
|
||||||
|
[bindings.ts](../../src/lib/api/bindings.ts) —
|
||||||
|
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
||||||
|
|
||||||
|
2. **Rust already honours `include_item_types` on both paths** — online
|
||||||
|
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
||||||
|
options mapping) and offline
|
||||||
|
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
||||||
|
type filter from it). **Do not add Rust code for filtering.**
|
||||||
|
|
||||||
|
3. **Per-page list search already does this correctly.**
|
||||||
|
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||||
|
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
||||||
|
the reference for the call shape, including the `requestId` handling.
|
||||||
|
|
||||||
|
4. **The gap is exactly one function.**
|
||||||
|
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
||||||
|
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
||||||
|
any scope. Both callers
|
||||||
|
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
||||||
|
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
||||||
|
it.
|
||||||
|
|
||||||
|
5. **Group order is hardcoded in markup.**
|
||||||
|
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||||
|
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
||||||
|
renders three fixed sections in source order.
|
||||||
|
|
||||||
|
6. **Frontend preferences persist via `localStorage`**, per the existing
|
||||||
|
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
||||||
|
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
||||||
|
command for this.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Scope model
|
||||||
|
|
||||||
|
One `SearchScope` type, defined once and shared:
|
||||||
|
|
||||||
|
| Scope | `includeItemTypes` | Chip label |
|
||||||
|
|-------|--------------------|------------|
|
||||||
|
| `all` | *unset* | All |
|
||||||
|
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
||||||
|
| `movies` | `Movie` | Movies |
|
||||||
|
| `tv` | `Series`, `Episode` | TV |
|
||||||
|
|
||||||
|
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
||||||
|
the two are not equivalent for item types not enumerated here (Person, folders).
|
||||||
|
|
||||||
|
### Route → scope resolution (DR-063)
|
||||||
|
|
||||||
|
A pure function, unit-testable without a DOM:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolveSearchScope(pathname: string): SearchScope
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/library/music*` → `music`
|
||||||
|
- `/library/movies*` → `movies`
|
||||||
|
- `/library/tv*` → `tv`
|
||||||
|
- `/`, `/library`, `/search`, anything else → `all`
|
||||||
|
|
||||||
|
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
||||||
|
current route list before finalising — do not assume this table is exhaustive.
|
||||||
|
|
||||||
|
### Scope is a starting point, not a lock (DR-064)
|
||||||
|
|
||||||
|
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
||||||
|
their choice governs until they leave the search surface. Concretely: derive the
|
||||||
|
initial value from the route, hold it in component state, and do not re-derive
|
||||||
|
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
||||||
|
|
||||||
|
Changing a chip re-runs the current query at the new scope. Changing the query
|
||||||
|
keeps the current scope.
|
||||||
|
|
||||||
|
### Threading scope through the store (DR-065)
|
||||||
|
|
||||||
|
Extend the store's search signature to accept an optional scope and pass
|
||||||
|
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
||||||
|
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
||||||
|
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
||||||
|
additive parameter — no caller should break.
|
||||||
|
|
||||||
|
### Group order (DR-066, DR-067)
|
||||||
|
|
||||||
|
Persist an ordered array of group ids:
|
||||||
|
|
||||||
|
```
|
||||||
|
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendering composes scope and order as **two independent axes**, in this order:
|
||||||
|
|
||||||
|
1. drop groups outside the active scope,
|
||||||
|
2. sort the remainder by the user's saved order,
|
||||||
|
3. omit groups that came back empty.
|
||||||
|
|
||||||
|
Scope never rewrites the saved order — narrowing to Music and back to All must
|
||||||
|
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
||||||
|
the worked example.
|
||||||
|
|
||||||
|
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
||||||
|
keyboard-operable move up/down controls with proper labels, or the setting is
|
||||||
|
unusable with a screen reader and on any pointerless input.
|
||||||
|
|
||||||
|
Unknown or missing ids in the stored array must not crash rendering — treat the
|
||||||
|
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
||||||
|
no longer exist. A user upgrading from a build with fewer groups must not lose
|
||||||
|
the new ones.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Ranking *within* a group. Order is presentation-only.
|
||||||
|
- Server-side search ranking or the Jellyfin query itself.
|
||||||
|
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
||||||
|
is already implicitly scoped by its own `itemType`.
|
||||||
|
- Any Rust change.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
||||||
|
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
||||||
|
- [ ] The chip row renders under the search bar on both the search page and the
|
||||||
|
in-library header search, with the context-derived chip preselected.
|
||||||
|
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
||||||
|
query preserves the selected chip.
|
||||||
|
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
||||||
|
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
||||||
|
empty groups omitted and relative order preserved.
|
||||||
|
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
||||||
|
restarts, and ships with the documented default.
|
||||||
|
- [ ] Offline search respects scope (the offline path already filters — verify,
|
||||||
|
don't reimplement).
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
||||||
|
|
||||||
|
- `resolveSearchScope` — pure unit tests over the route table, including the
|
||||||
|
`/library/shows/genres` case and unknown routes falling back to `all`.
|
||||||
|
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
||||||
|
- The compose step: scope filter + user order + empty-group omission, including
|
||||||
|
the "narrow then widen restores order" case and a stored order containing an
|
||||||
|
unknown id.
|
||||||
|
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
||||||
|
the existing stale-`requestId` guard still discards superseded responses.
|
||||||
|
|
||||||
|
New requirement-implementing code needs `TRACES:` comments — see
|
||||||
|
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
||||||
|
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
||||||
|
and ordered rendering `UR-050 | DR-066, DR-067`.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
||||||
|
document is the implementation plan.
|
||||||
|
- The IPC camelCase rule applies to anything new that crosses the boundary
|
||||||
|
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
||||||
|
- Another session may be active in this repo. Check `git diff` before
|
||||||
|
"repairing" unexpected changes.
|
||||||
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
|||||||
|
|
||||||
## Gitea Actions Workflows
|
## Gitea Actions Workflows
|
||||||
|
|
||||||
Two workflows are configured in `.gitea/workflows/`:
|
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||||
|
|
||||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
|
||||||
Gitea-native workflow with:
|
|
||||||
- ✅ Automatic trace extraction
|
- ✅ Automatic trace extraction
|
||||||
- ✅ Coverage validation against minimum threshold (50%)
|
- ✅ Coverage validation against minimum threshold (50%)
|
||||||
- ✅ Modified file checking
|
- ✅ Modified file checking
|
||||||
- ✅ Artifact preservation
|
- ✅ Artifact preservation
|
||||||
- ✅ Summary reports
|
- ✅ Summary reports
|
||||||
|
|
||||||
**Runs on:** Every push and pull request
|
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||||
|
|
||||||
### 2. `traceability.yml` (Alternative)
|
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||||
GitHub-compatible workflow with additional features:
|
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||||
- Pull request comments with coverage stats
|
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||||
- GitHub-specific integrations
|
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
||||||
|
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
||||||
|
`traceability-check.yml` rather than reviving the old file.
|
||||||
|
|
||||||
## What Gets Validated
|
## What Gets Validated
|
||||||
|
|
||||||
|
|||||||
+1294
-470
File diff suppressed because it is too large
Load Diff
+529
-80
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
|
|||||||
- Logo (links to `/library`)
|
- Logo (links to `/library`)
|
||||||
- Navigation links: Home, Library, Downloads, Settings
|
- Navigation links: Home, Library, Downloads, Settings
|
||||||
- Search bar (inline)
|
- Search bar (inline)
|
||||||
- User menu: Username, Downloads icon, Logout button
|
- Account menu (see §1.2)
|
||||||
|
|
||||||
**Mobile Navigation:**
|
**Mobile Navigation:**
|
||||||
|
|
||||||
On mobile, the header contains:
|
On mobile, the header contains:
|
||||||
- Logo
|
- Logo
|
||||||
- Three-dot overflow menu button (Android-style)
|
- Account menu button (see §1.2)
|
||||||
- Overflow menu includes:
|
|
||||||
- Downloads
|
### 1.2 Account Menu
|
||||||
- Settings
|
|
||||||
- Sign out
|
Account-level destinations — the ones that are *about the user* rather than
|
||||||
|
about media — live behind a single **account menu**, anchored to the user's
|
||||||
|
name/avatar at the right of the header.
|
||||||
|
|
||||||
|
**Contents, in order:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ Signed in as <name> │ ← identity, not a menu item
|
||||||
|
│ <server host> │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ ⬇ Downloads │
|
||||||
|
│ ⚙ Settings │
|
||||||
|
│ ▦ Display │ ← grid/list preference (§5A.2)
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ ⇥ Sign out │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- **One menu, both platforms.** Desktop and mobile show the same items in the
|
||||||
|
same order. A user who learns where Settings lives on one form factor finds
|
||||||
|
it in the same place on the other.
|
||||||
|
- **Anchored to identity.** The trigger is the username/avatar, because that is
|
||||||
|
where users look for account actions. A bare three-dot icon does not signal
|
||||||
|
"your account".
|
||||||
|
- **Sign out is separated** by a divider and placed last — it is destructive and
|
||||||
|
must not sit adjacent to routine navigation.
|
||||||
|
- **The menu is reachable from every authenticated screen**, not only from
|
||||||
|
library routes. See §1.3.
|
||||||
|
|
||||||
**Access Points Summary:**
|
**Access Points Summary:**
|
||||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
- **Downloads** → header icon (desktop) + account menu (both)
|
||||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
- **Settings** → header nav link (desktop) + account menu (both)
|
||||||
|
- **Sign out** → account menu only
|
||||||
|
|
||||||
|
### 1.3 Chrome availability
|
||||||
|
|
||||||
|
The header is shared across chrome-bearing routes. Routes fall into three groups:
|
||||||
|
|
||||||
|
| Route group | Header | Bottom nav | Account menu reachable? |
|
||||||
|
|-------------|--------|------------|-------------------------|
|
||||||
|
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
|
||||||
|
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
|
||||||
|
| `/settings` | Own layout | No | n/a — already there |
|
||||||
|
| `/player/*`, `/login` | No | No | No (by design) |
|
||||||
|
|
||||||
|
The rule the app honours: every authenticated, non-immersive screen exposes the
|
||||||
|
account menu. Only the full-screen player and the login screen are chrome-free.
|
||||||
|
|
||||||
|
### 1.4 Known deviations
|
||||||
|
|
||||||
|
*(None — the account-menu and chrome-availability defects tracked here under
|
||||||
|
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
|
||||||
|
from every authenticated non-immersive screen via the shared `AccountMenu`, the
|
||||||
|
username/avatar is the menu trigger, desktop and mobile share one menu, and the
|
||||||
|
Display preference has a Settings entry — UR-029, §5A.4.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -386,9 +439,9 @@ flowchart TB
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
|
||||||
|
|
||||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
|
||||||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||||
|
|
||||||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||||
@@ -445,54 +498,352 @@ flowchart TB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Search Flow
|
## 5A. Library Page Layouts
|
||||||
|
|
||||||
### 6.1 Search Page Navigation
|
Every browse page is one of two shapes: a **card grid** or a **row list**. This
|
||||||
|
section is the rule for which shape a page takes, what a card looks like, and
|
||||||
|
what the user is allowed to change.
|
||||||
|
|
||||||
|
### 5A.1 Card shape follows the media, not the page
|
||||||
|
|
||||||
|
Card aspect ratio is a property of *what the item is*, and is never overridden
|
||||||
|
per-page. This is the single most important layout rule: a user scanning a grid
|
||||||
|
recognises content type by silhouette before reading a word.
|
||||||
|
|
||||||
|
| Item type | Aspect | Rationale |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
|
||||||
|
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
|
||||||
|
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
|
||||||
|
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
|
||||||
|
|
||||||
|
Artist cards are square but rendered **circular-masked**, so artists are
|
||||||
|
distinguishable from albums at a glance within the same music grid.
|
||||||
|
|
||||||
|
### 5A.2 Grid vs. list
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
Page[Library browse page] --> Kind{Content kind}
|
||||||
|
|
||||||
ClickSearch --> SearchPage[Search Page<br/>/search]
|
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
|
||||||
|
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
|
||||||
|
|
||||||
SearchPage --> EmptyState{Has Query?}
|
Grid --> Toggle[View toggle in page header]
|
||||||
|
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||||||
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
|
||||||
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
|
||||||
|
|
||||||
ShowPrompt --> UserTypes[User Types in Search]
|
|
||||||
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
|
||||||
LiveSearch --> ShowResults
|
|
||||||
|
|
||||||
ShowResults --> UserClick{User Clicks Result}
|
|
||||||
|
|
||||||
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
|
||||||
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
|
||||||
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
|
||||||
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Search Page Layout:**
|
- **Grids are the default** for anything with cover art worth scanning.
|
||||||
|
- **Lists are mandatory, not optional**, where position carries meaning —
|
||||||
|
a track's number within an album, an episode's number within a season.
|
||||||
|
A grid destroys that ordering cue, so these pages expose **no toggle**.
|
||||||
|
- **The toggle is global, not per-page.** A user who prefers dense lists
|
||||||
|
prefers them everywhere; making them re-set it on each page is friction.
|
||||||
|
The choice persists across launches.
|
||||||
|
|
||||||
|
**Responsive columns** (grid mode), tuned so cards stay large enough to read
|
||||||
|
cover art on a phone and don't become postage stamps on a desktop:
|
||||||
|
|
||||||
|
| Breakpoint | Columns |
|
||||||
|
|------------|---------|
|
||||||
|
| base (phone) | 2 |
|
||||||
|
| sm | 3 |
|
||||||
|
| md | 4 |
|
||||||
|
| lg | 5 |
|
||||||
|
| xl | 6 |
|
||||||
|
|
||||||
|
### 5A.3 What a card shows
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ │ ← cover art (aspect per §5A.1)
|
||||||
|
│ artwork │ • progress bar overlay if partially played
|
||||||
|
│ │ • watched/played check if complete
|
||||||
|
│ [▶] │ • play affordance on hover/focus
|
||||||
|
└─────────────┘
|
||||||
|
Primary line ← title, truncated to one line
|
||||||
|
Secondary line ← artist / year+rating / SxEy — one line, dimmed
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
|
||||||
|
grows to fit its title breaks grid alignment and makes scanning harder.
|
||||||
|
- **Progress and watched state live on the artwork**, not in the text — they
|
||||||
|
must be readable while scanning, without reading.
|
||||||
|
- **Hover/focus reveals play**, so a card is both a navigation target and a
|
||||||
|
playback target without a second control competing for space at rest.
|
||||||
|
|
||||||
|
### 5A.4 Known deviations
|
||||||
|
|
||||||
|
These are places the implementation currently diverges from the rules above.
|
||||||
|
They are recorded here so the gap is explicit rather than mistaken for intent.
|
||||||
|
|
||||||
|
- **The view toggle is discoverable only on a browse page.** The preference is
|
||||||
|
already global and persisted, but the only control that sets it is the pair
|
||||||
|
of icon buttons in a library page header. Settings has no display section, so
|
||||||
|
there is nowhere to look for it. *(UR-029)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5B. Video Detail Page Composition
|
||||||
|
|
||||||
|
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
|
||||||
|
surface renders is decided by item type plus the `?episode=` query param, and
|
||||||
|
**section order is part of the spec** — it is what makes "keep watching this
|
||||||
|
show" the path of least resistance.
|
||||||
|
|
||||||
|
### 5B.1 Which surface renders
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Nav[Navigate to /library/[id]] --> Type{Item type}
|
||||||
|
|
||||||
|
Type -->|Person| Person[PersonDetailView]
|
||||||
|
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
|
||||||
|
Type -->|Series| Ep{?episode= param<br/>present?}
|
||||||
|
|
||||||
|
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
|
||||||
|
Ep -->|No| Series[Series detail<br/>§5B.4]
|
||||||
|
|
||||||
|
Focus -->|Back to series| Series
|
||||||
|
Series -->|Click episode| Focus
|
||||||
|
```
|
||||||
|
|
||||||
|
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
||||||
|
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
|
||||||
|
episode is always shown in the context of its series and the series' full
|
||||||
|
episode list is already loaded.
|
||||||
|
|
||||||
|
### 5B.2 Episode Focus View — section order
|
||||||
|
|
||||||
|
**The next episodes appear directly below the current episode, above cast and
|
||||||
|
similar shows.** Nothing may be inserted between the episode hero and the
|
||||||
|
episode strip.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [←] │
|
||||||
|
│ ┌───────────────────────────────────────────┐ │
|
||||||
|
│ │ episode backdrop │ │
|
||||||
|
│ │ Series Name │ │ ← 1. HERO
|
||||||
|
│ │ Episode Title │ │
|
||||||
|
│ │ S2E4 • 48m • ★8.1 │ │
|
||||||
|
│ │ Overview… │ │
|
||||||
|
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||||
|
│ │ [▶ Play] │ │
|
||||||
|
│ └───────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ More Episodes │ ← 2. EPISODE STRIP
|
||||||
|
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
|
||||||
|
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
|
||||||
|
│ │ ││NOW ││ ││ │ │
|
||||||
|
│ └──────┘└──────┘└──────┘└──────┘ │
|
||||||
|
│ │
|
||||||
|
│ Cast │ ← 3. CAST
|
||||||
|
│ ( ○ )( ○ )( ○ )( ○ ) │
|
||||||
|
│ │
|
||||||
|
│ More Like This │ ← 4. SIMILAR
|
||||||
|
│ ┌────┐┌────┐┌────┐┌────┐ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules for the episode strip:**
|
||||||
|
|
||||||
|
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
|
||||||
|
between the current episode and every other section; cast and related
|
||||||
|
content are *below* it, never above.
|
||||||
|
- **Window, not full list.** The strip shows a window around the current
|
||||||
|
episode — roughly 3 before and 6 after — so the immediate next episodes are
|
||||||
|
visible without scrolling, and earlier ones remain reachable by scrolling
|
||||||
|
left. It is horizontally scrollable, not a wrapped grid.
|
||||||
|
- **Forward bias.** More episodes are shown *after* the current one than
|
||||||
|
before it: the dominant intent on this screen is "watch the next one."
|
||||||
|
- **The current episode is present and marked.** It renders in-strip with a
|
||||||
|
"NOW" badge and a highlight ring, and is not clickable. It anchors the
|
||||||
|
user's position in the season rather than being hidden.
|
||||||
|
- **Cross-season continuity.** The window spans the whole series in episode
|
||||||
|
order, so the strip runs past a season boundary into the next season's first
|
||||||
|
episodes rather than dead-ending at the end of a season.
|
||||||
|
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
|
||||||
|
progress bar when partially watched, and a watched checkmark when complete.
|
||||||
|
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
|
||||||
|
not start playback. Playback starts only from the hero's Play button.
|
||||||
|
|
||||||
|
### 5B.3 Movie detail — section order
|
||||||
|
|
||||||
|
```
|
||||||
|
Hero (poster, title, metadata, Play / Download / Favorite)
|
||||||
|
→ Crew links (Directed by / Written by / Music by)
|
||||||
|
→ Genre tags
|
||||||
|
→ Cast
|
||||||
|
→ More Like This
|
||||||
|
```
|
||||||
|
|
||||||
|
A movie has no continuation set, so cast follows the hero directly.
|
||||||
|
|
||||||
|
### 5B.4 Series detail — section order
|
||||||
|
|
||||||
|
```
|
||||||
|
Hero (poster, title, metadata, Play / Download)
|
||||||
|
→ Crew links
|
||||||
|
→ Genre tags
|
||||||
|
→ Seasons + episodes (per-season sections)
|
||||||
|
→ Cast
|
||||||
|
→ More Like This
|
||||||
|
```
|
||||||
|
|
||||||
|
The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||||
|
The reason a user opens a series page is to pick an episode; discovery content
|
||||||
|
is secondary and sits underneath.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Search Flow
|
||||||
|
|
||||||
|
Search is **context-scoped**: what you are looking at when you start a search
|
||||||
|
determines what the search covers. A search begun inside the Music library
|
||||||
|
searches music. A search begun from Home or the top-level library page searches
|
||||||
|
everything. The scope is always shown, and always overridable.
|
||||||
|
|
||||||
|
### 6.1 Scope is inherited from context
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Start[User starts a search] --> Where{Where from?}
|
||||||
|
|
||||||
|
Where -->|Home (/)| All[Scope: All]
|
||||||
|
Where -->|Library root (/library)| All
|
||||||
|
Where -->|Search tab| All
|
||||||
|
Where -->|Inside Music| Music[Scope: Music]
|
||||||
|
Where -->|Inside Movies| Movies[Scope: Movies]
|
||||||
|
Where -->|Inside TV| TV[Scope: TV]
|
||||||
|
|
||||||
|
All --> Chips[Filter chips shown<br/>All chip selected]
|
||||||
|
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
|
||||||
|
Movies --> Chips2
|
||||||
|
TV --> Chips2
|
||||||
|
|
||||||
|
Chips --> Results[Results, grouped by type]
|
||||||
|
Chips2 --> Results
|
||||||
|
|
||||||
|
Results --> Change{User taps a chip}
|
||||||
|
Change --> Rescope[Re-run search at new scope<br/>query preserved]
|
||||||
|
Rescope --> Results
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- **Context sets the *initial* chip, never a locked filter.** Entering search
|
||||||
|
from TV preselects the TV chip; the user can tap "All" to widen without
|
||||||
|
retyping the query. Scope is a starting point, not a cage.
|
||||||
|
- **Home, `/library`, and the search tab all start at "All".** These are the
|
||||||
|
places a user has expressed no narrower intent.
|
||||||
|
- **Changing scope preserves the query** and re-runs the search. Changing the
|
||||||
|
query preserves the scope.
|
||||||
|
- **Scope maps to item types**, resolved at the point of search:
|
||||||
|
|
||||||
|
| Chip | `includeItemTypes` |
|
||||||
|
|------|--------------------|
|
||||||
|
| All | *(unset — every type)* |
|
||||||
|
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
|
||||||
|
| Movies | `Movie` |
|
||||||
|
| TV | `Series`, `Episode` |
|
||||||
|
|
||||||
|
- **Chips render under the search bar**, on both the dedicated search page and
|
||||||
|
the in-library header search. They are horizontally scrollable if they
|
||||||
|
overflow, never wrapped onto a second row.
|
||||||
|
|
||||||
|
### 6.2 Search page layout
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ [🔍 Search...] [✕] │
|
│ [🔍 Search...] [✕] │
|
||||||
│ │
|
│ │
|
||||||
|
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
|
||||||
|
│ │
|
||||||
│ Songs ──────────────────────────── │
|
│ Songs ──────────────────────────── │
|
||||||
│ ♪ Song Title - Artist 3:45 │
|
│ ♪ Song Title - Artist 3:45 │
|
||||||
│ ♪ Song Title - Artist 4:12 │
|
│ ♪ Song Title - Artist 4:12 │
|
||||||
│ See all (23) │
|
│ See all (23) │
|
||||||
│ │
|
│ │
|
||||||
│ Albums ─────────────────────────── │
|
│ Albums ─────────────────────────── │
|
||||||
│ [Album Cover] Album Title │
|
│ [Cover] Album Title │
|
||||||
│ [Album Cover] Album Title │
|
|
||||||
│ See all (8) │
|
│ See all (8) │
|
||||||
│ │
|
│ │
|
||||||
│ Artists ────────────────────────── │
|
│ Artists ────────────────────────── │
|
||||||
│ [Photo] Artist Name │
|
│ ( Photo ) Artist Name │
|
||||||
│ See all (5) │
|
│ See all (5) │
|
||||||
└─────────────────────────────────────────┘
|
└─────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Results stay **grouped by type** even when a scope is selected — a Music
|
||||||
|
search still separates Songs / Albums / Artists.
|
||||||
|
- Each group shows a bounded preview with a **See all (n)** affordance rather
|
||||||
|
than an unbounded list, so no single type can bury the others.
|
||||||
|
- Live search is **debounced** as the user types; a query that becomes empty
|
||||||
|
clears results rather than searching for the empty string.
|
||||||
|
|
||||||
|
### 6.3 Result group order is user-configurable
|
||||||
|
|
||||||
|
Which *kind* of thing a user is usually searching for is personal: a
|
||||||
|
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
|
||||||
|
than guessing, the group order is a setting.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Settings[Settings → Search] --> List[Draggable list of result groups]
|
||||||
|
|
||||||
|
List --> Drag[User drags a group up or down]
|
||||||
|
Drag --> Persist[Order persisted]
|
||||||
|
|
||||||
|
Persist --> Render[Rendering a result set]
|
||||||
|
Scope[Active scope chip §6.1] --> Render
|
||||||
|
|
||||||
|
Render --> Filter[1 - Drop groups outside the active scope]
|
||||||
|
Filter --> Sort[2 - Sort remaining groups by user order]
|
||||||
|
Sort --> Prune[3 - Omit groups with no results]
|
||||||
|
Prune --> Show[Render]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scope and order compose — they are two independent axes.** The scope chip
|
||||||
|
decides *which* groups are eligible; the settings list decides *what sequence*
|
||||||
|
the eligible ones appear in. Order is preserved as a relative ranking, never
|
||||||
|
renumbered per scope:
|
||||||
|
|
||||||
|
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
|
||||||
|
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
|
||||||
|
groups keep their relative order.
|
||||||
|
- Scope **All** with the same setting renders all five in exactly that order.
|
||||||
|
- **Changing scope never rewrites the saved order.** A user who narrows to
|
||||||
|
Music and back to All sees their original arrangement intact.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- **Drag and drop to reorder**, in a settings list showing every result group
|
||||||
|
(Songs, Albums, Artists, Movies, TV Shows).
|
||||||
|
- **The order applies to grouped results everywhere** — the search page and
|
||||||
|
the in-library header search alike.
|
||||||
|
- **Order is presentation-only.** It never changes which results are returned
|
||||||
|
or how they are ranked *within* a group, only the sequence groups appear in.
|
||||||
|
- **Empty groups are skipped, not gapped.** A group with no results is omitted
|
||||||
|
entirely; it does not reserve space or leave a stray heading.
|
||||||
|
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
|
||||||
|
so the setting is an adjustment, never a prerequisite.
|
||||||
|
- **Keyboard/accessible reordering must exist** alongside dragging — a
|
||||||
|
drag-only control is unusable with a screen reader or without a pointer.
|
||||||
|
|
||||||
|
### 6.4 Known deviations
|
||||||
|
|
||||||
|
Recorded so the gap between this spec and the build is explicit.
|
||||||
|
|
||||||
|
- **Scope is not implemented.** The in-library header search calls the same
|
||||||
|
unscoped query as the global search page, so searching inside TV returns
|
||||||
|
music. The backend already accepts `includeItemTypes` on both the online and
|
||||||
|
offline paths, and the per-page list search already uses it — only the global
|
||||||
|
path ignores it. *(UR-049)*
|
||||||
|
- **Filter chips do not exist** on either search surface. *(UR-049)*
|
||||||
|
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
|
||||||
|
with no setting. *(UR-050)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Download Flows
|
## 7. Download Flows
|
||||||
@@ -532,68 +883,166 @@ States:
|
|||||||
5. [⏸] Paused - Yellow pause icon
|
5. [⏸] Paused - Yellow pause icon
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 Managing Downloads Page
|
### 7.2 Downloads = a browsable offline library, not a flat list
|
||||||
|
|
||||||
|
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
|
||||||
|
*the library, filtered to what's on the device*. A user who has downloaded three
|
||||||
|
seasons of a show and two albums thinks in terms of shows and albums, not
|
||||||
|
seventy-odd individual episode/track transfers. So the primary Downloads surface
|
||||||
|
**reuses the library browse screens**, scoped to downloaded content, and keeps
|
||||||
|
the transfer-progress list as a secondary "Transfers" view for the *act* of
|
||||||
|
downloading.
|
||||||
|
|
||||||
|
This splits one overloaded page into two clear jobs:
|
||||||
|
|
||||||
|
| Surface | Answers | Reuses |
|
||||||
|
|---------|---------|--------|
|
||||||
|
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
|
||||||
|
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
User[User] --> NavChoice{Navigation Path}
|
Nav[Open Downloads] --> Downloads[/downloads]
|
||||||
|
|
||||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
Downloads --> View{View}
|
||||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
View -->|Downloaded (default)| Browse[Offline library browse]
|
||||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
View -->|Transfers| Activity[Transfer activity list]
|
||||||
|
|
||||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||||||
HeaderIcon --> DownloadsPage
|
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||||||
TypeURL --> DownloadsPage
|
Grid --> Detail[Detail page<br/>same as online]
|
||||||
|
Detail --> Play[Play from local file]
|
||||||
|
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
|
||||||
|
|
||||||
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||||||
|
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||||||
ShowTabs --> ActiveTab{Active Tab}
|
|
||||||
|
|
||||||
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
|
||||||
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
|
||||||
|
|
||||||
ShowActive --> UserAction1{User Action}
|
|
||||||
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
|
||||||
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
|
||||||
|
|
||||||
ShowCompleted --> UserAction2{User Action}
|
|
||||||
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
|
||||||
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Navigation to Downloads:**
|
**Why reuse the library screens (not a bespoke list):**
|
||||||
- **Desktop:** Click "Downloads" link in header navigation
|
|
||||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
- **One mental model.** Browsing offline should feel identical to browsing
|
||||||
- **Direct:** Navigate to `/downloads` route
|
online — same grids, same card shapes, same detail pages, same play action.
|
||||||
|
The only difference is *what's present*, not *how it looks*.
|
||||||
|
- **It already works in the backend.** The offline repository's `get_items`
|
||||||
|
already returns downloaded items **plus** their containers (an album with any
|
||||||
|
downloaded track, a series/season with any downloaded episode). That is a
|
||||||
|
browsable tree today — see §7.4.
|
||||||
|
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
|
||||||
|
browsable library does not.
|
||||||
|
|
||||||
|
### 7.3 The Downloaded browse surface
|
||||||
|
|
||||||
**Downloads Page Layout:**
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────────┐
|
||||||
│ [←] Downloads │
|
│ Downloads │
|
||||||
|
│ ( Downloaded ) ( Transfers ) ← view switch
|
||||||
│ │
|
│ │
|
||||||
│ [Active (3)] [Completed (12)] │
|
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||||||
│ │
|
│ │
|
||||||
│ ─ Downloading ──────────────────── │
|
│ Music │ ← only libraries that
|
||||||
|
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||||||
|
│ │alb ││alb ││art │ │
|
||||||
|
│ └────┘└────┘└────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ Album Cover Album Title │
|
│ TV │
|
||||||
│ Artist Name │
|
│ ┌────┐┌────┐ │
|
||||||
│ [████████░░] 80% │
|
│ │show││show│ │
|
||||||
│ [⏸ Pause] [✕ Cancel] │
|
│ └────┘└────┘ │
|
||||||
│ │
|
└─────────────────────────────────────────────┘
|
||||||
│ Album Cover Album Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ [██░░░░░░░░] 20% │
|
|
||||||
│ [⏸ Pause] [✕ Cancel] │
|
|
||||||
│ │
|
|
||||||
│ ─ Queued ───────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Album Cover Album Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ Waiting... │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
|
||||||
|
music is downloaded, only Music appears.
|
||||||
|
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
|
||||||
|
browse is the same components with an offline-scoped data source, never a
|
||||||
|
parallel re-implementation.
|
||||||
|
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
|
||||||
|
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
|
||||||
|
- **Disk usage is shown where the user already looks**, in familiar units — see
|
||||||
|
§7.3.1.
|
||||||
|
- **Play always plays the local file** here; nothing on this surface streams.
|
||||||
|
- **Remove is available at every level** — item, album/season, series — and
|
||||||
|
states clearly what it frees. Removing the last downloaded child of a
|
||||||
|
container removes the container from the browse.
|
||||||
|
- **This surface works identically online and offline.** It is "what's on the
|
||||||
|
device," a question whose answer does not depend on connectivity. It must not
|
||||||
|
wait for, or be emptied by, server reachability.
|
||||||
|
|
||||||
|
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
|
||||||
|
|
||||||
|
Users want to know what each thing costs on disk, but that information has to
|
||||||
|
feel like the storage views they already know (phone Settings → Storage, a
|
||||||
|
file browser), not a developer's byte dump.
|
||||||
|
|
||||||
|
- **Size rides along with the item, on the card and the detail page** — a small
|
||||||
|
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
|
||||||
|
report" screen the user has to go find.
|
||||||
|
- **Containers show their total.** A series shows the sum of its downloaded
|
||||||
|
episodes; an album the sum of its tracks; a season its own subtotal. The
|
||||||
|
number a user sees on the "Breaking Bad" card is what removing it frees.
|
||||||
|
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
|
||||||
|
and use it everywhere. Show 2–3 significant figures (`1.2 GB`, not
|
||||||
|
`1,283,048,192 bytes` and not `1.28394 GB`).
|
||||||
|
- **A single device total sits at the top** of the Downloaded surface
|
||||||
|
(`3.4 GB on device · 12 items`) so the headline number is answered before the
|
||||||
|
user scans. It reconciles with the sum of what's listed.
|
||||||
|
- **Remove restates the reclaim** in the same units at the point of action
|
||||||
|
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
|
||||||
|
legible exactly when the user decides.
|
||||||
|
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
|
||||||
|
what to clear) but is not required for v1.
|
||||||
|
|
||||||
|
The bytes-on-disk per item are a backend fact (the download manager writes the
|
||||||
|
files and can stat them); this is a display and aggregation task, not new
|
||||||
|
tracking. See §7.7 deviations for what's missing today.
|
||||||
|
|
||||||
|
### 7.4 Transfers (activity) view
|
||||||
|
|
||||||
|
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
|
||||||
|
It is about *transfers in flight*, so it shows only rows that are doing or
|
||||||
|
waiting to do something:
|
||||||
|
|
||||||
|
- **States:** downloading (with progress), queued, paused, failed,
|
||||||
|
waiting-for-WiFi (§7.5).
|
||||||
|
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
|
||||||
|
and auto-pump are backend concerns and are not surfaced as manual controls.
|
||||||
|
- **Completed transfers fall off this view** once done — the finished item lives
|
||||||
|
in Downloaded, not here. A transient "just finished" confirmation is fine; a
|
||||||
|
permanent completed-list is not (that's what Downloaded is for).
|
||||||
|
- **Empty state** points at the library: "Nothing downloading. Browse your
|
||||||
|
library and tap download to save media for offline."
|
||||||
|
|
||||||
|
### 7.5 Navigation & entry points
|
||||||
|
|
||||||
|
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
|
||||||
|
link/icon → `/downloads`.
|
||||||
|
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
|
||||||
|
and should draw attention (badge/count) only while transfers are active.
|
||||||
|
- Initiating a download is unchanged (§7.1): the download button lives on
|
||||||
|
item/album/series detail pages. The Downloads page manages and browses; it is
|
||||||
|
not where you start a download.
|
||||||
|
|
||||||
|
### 7.7 Known deviations
|
||||||
|
|
||||||
|
Recorded so the gap between this spec and the build is explicit.
|
||||||
|
|
||||||
|
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
|
||||||
|
row per individual transfer with no browsing, grouping, or reuse of the
|
||||||
|
library screens. Completed downloads never collapse into their album/series.
|
||||||
|
*(UR-055)*
|
||||||
|
- **No offline-scoped browse entry point exists in the client.** All browsing
|
||||||
|
goes through the hybrid repository, which merges cache **and** server; there is
|
||||||
|
no way to ask for "downloaded content only" as a browse surface. The offline
|
||||||
|
repository supports it (§7.2) but is not reachable independently. *(UR-055,
|
||||||
|
DR-082)*
|
||||||
|
- **The "on device" storage summary and per-container remove** are absent from
|
||||||
|
the completed list. *(UR-055, UR-056)*
|
||||||
|
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
|
||||||
|
no size; there is no device total, no container subtotal, and Remove does not
|
||||||
|
state what it frees. *(UR-056)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Settings & Account Flows
|
## 8. Settings & Account Flows
|
||||||
|
|||||||
Reference in New Issue
Block a user