🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md
613 lines
67 KiB
Markdown
613 lines
67 KiB
Markdown
# Requirements Specification
|
||
|
||
This document captures JellyTau's user requirements, software requirements,
|
||
traceability matrix, test traceability, and known technical debt.
|
||
|
||
For a narrative overview of the system design, see
|
||
[docs/architecture/](architecture/). For development workflows, see the
|
||
[README](../README.md) and [scripts/README.md](../scripts/README.md).
|
||
|
||
## 1. User Requirements
|
||
|
||
| ID | Requirement | Priority | Status |
|
||
|----|-------------|----------|--------|
|
||
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
||
| UR-002 | Access media when online or offline | High | Done |
|
||
| UR-003 | Play videos | High | Done |
|
||
| UR-004 | Play audio uninterrupted | High | Done |
|
||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
||
| UR-007 | Navigate media in library | High | Done |
|
||
| UR-008 | Search media across libraries | High | Done |
|
||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
|
||
| UR-011 | Download media on demand | Medium | Done |
|
||
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
||
| UR-013 | View and manage downloaded media | Medium | Done |
|
||
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
|
||
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
||
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
||
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
||
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
||
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
||
| UR-020 | Select subtitles for video content | High | Done |
|
||
| UR-021 | Select audio track for video content | High | Done |
|
||
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
||
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
||
| UR-024 | View recently added content on server | Medium | Done |
|
||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
||
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
|
||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||
| UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034) |
|
||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
|
||
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
|
||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
||
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
||
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
||
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
|
||
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
|
||
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
|
||
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
|
||
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | 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-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 | Done |
|
||
| 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 | Done |
|
||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
|
||
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
||
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
|
||
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
|
||
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
|
||
|
||
---
|
||
|
||
## 2. Software Requirements
|
||
|
||
### 2.1 Integration Requirements
|
||
|
||
External system integrations and platform-specific implementations.
|
||
|
||
| ID | Requirement | Category | Traces To | Status |
|
||
|----|-------------|----------|-----------|--------|
|
||
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
||
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
||
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
||
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
||
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
||
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
||
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
||
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
||
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
||
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
|
||
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
||
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
||
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
|
||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
||
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
|
||
| 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-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
|
||
|
||
API endpoints and data contracts required for Jellyfin integration.
|
||
|
||
| ID | Requirement | Endpoint Category | Traces To | Status |
|
||
|----|-------------|-------------------|-----------|--------|
|
||
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
||
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
||
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
||
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
||
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
||
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
||
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
||
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
|
||
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
|
||
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
||
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
||
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
||
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
||
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
|
||
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
|
||
| JA-016 | Get recently added items | Items | UR-024 | Done |
|
||
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
||
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
||
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
|
||
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
|
||
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
|
||
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
|
||
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
|
||
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
|
||
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
|
||
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
|
||
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
||
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
||
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
|
||
|
||
### 2.3 Development Requirements
|
||
|
||
Internal architecture, components, and application logic.
|
||
|
||
| ID | Requirement | Category | Traces To | Status |
|
||
|----|-------------|----------|-----------|--------|
|
||
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
||
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
||
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
||
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
||
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
||
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
||
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
||
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
||
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
||
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
|
||
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
||
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
||
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
||
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
|
||
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
||
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
||
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
||
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
||
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
|
||
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
||
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
||
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
||
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
|
||
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
|
||
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
||
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
||
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
||
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
||
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
||
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
||
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
|
||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md) |
|
||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
|
||
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
|
||
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
|
||
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
|
||
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
|
||
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
|
||
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
|
||
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
|
||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
||
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
||
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
||
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
|
||
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
|
||
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
|
||
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
|
||
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
|
||
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
|
||
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | 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-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 taxonomy owned by Rust: `SearchScope` (All / Music / Movies / TV) crosses IPC as an opaque enum and `SearchScope::item_types()` expands it to Jellyfin item types, resolved once in `repository_search` before the cache and server paths diverge so online and offline filter identically; `All` expands to *no* filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (`resolveSearchScope`, presentation) and never names an item type for search | Backend | UR-049 | Implemented |
|
||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
|
||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
|
||
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
|
||
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / −10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
|
||
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
|
||
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
|
||
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
|
||
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
|
||
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
|
||
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
|
||
| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done |
|
||
| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done |
|
||
| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection` → `EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done |
|
||
| DR-103 | A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done |
|
||
| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | Done |
|
||
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
|
||
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
|
||
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
|
||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||
|
||
---
|
||
|
||
## 3. Traceability Matrix
|
||
|
||
### User Requirements to Software Requirements
|
||
|
||
| User Req | Integration Requirements | Development Requirements |
|
||
|----------|-------------------------|-------------------------|
|
||
| UR-001 | IR-001, IR-002 | - |
|
||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||
| UR-008 | IR-010 | DR-007, DR-011 |
|
||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||
| UR-012 | IR-009, IR-014 | - |
|
||
| UR-013 | IR-013 | DR-017 |
|
||
| UR-014 | IR-010 | DR-014, DR-019 |
|
||
| UR-015 | - | DR-005, DR-020 |
|
||
| UR-016 | - | - |
|
||
| UR-017 | - | DR-014, DR-021 |
|
||
| UR-018 | IR-013 | DR-015, DR-018 |
|
||
| UR-019 | IR-015 | DR-022 |
|
||
| UR-020 | IR-016, IR-018 | DR-023 |
|
||
| UR-021 | IR-016, IR-019 | DR-024 |
|
||
| UR-022 | IR-017 | DR-025 |
|
||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||
| UR-024 | IR-010 | DR-027 |
|
||
| UR-025 | IR-015 | DR-028 |
|
||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||
| UR-027 | IR-020 | DR-030 |
|
||
| UR-028 | - | DR-031 |
|
||
| UR-029 | - | DR-032 |
|
||
| UR-030 | IR-010 | DR-033 |
|
||
| UR-031 | - | DR-034 |
|
||
| UR-032 | - | DR-035 |
|
||
| UR-033 | - | DR-036 |
|
||
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
||
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
||
| UR-036 | IR-022, IR-023 | DR-041 |
|
||
| UR-037 | IR-010 | DR-042 |
|
||
| UR-038 | IR-010 | DR-043 |
|
||
| UR-039 | - | DR-045, DR-046 |
|
||
| UR-040 | IR-025 | DR-051, DR-052 |
|
||
| UR-041 | IR-026 | DR-053 |
|
||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||
| UR-043 | IR-027 | DR-055 |
|
||
| UR-044 | - | DR-056 |
|
||
| UR-045 | - | DR-057 |
|
||
| UR-046 | IR-028 | DR-058 |
|
||
| 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 |
|
||
| UR-057 | - | DR-086 |
|
||
| UR-058 | - | DR-087 |
|
||
| UR-060 | - | DR-090, DR-091 |
|
||
| UR-061 | - | DR-092 |
|
||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||
| UR-063 | - | DR-105 |
|
||
| UR-064 | - | DR-106 |
|
||
|
||
---
|
||
|
||
## 4. Test Traceability
|
||
|
||
### Unit Tests to Software Requirements
|
||
|
||
| Test ID | Test Description | Traces To | Status |
|
||
|---------|-----------------|-----------|--------|
|
||
| UT-001 | Player state transitions | DR-001 | Pending |
|
||
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
||
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
||
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
||
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
||
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
||
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
||
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
||
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
||
| UT-010 | Download queue management | DR-015 | Done |
|
||
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
||
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
||
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
||
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
||
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
||
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
||
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
||
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
||
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
||
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
||
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
||
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
||
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
||
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
||
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
||
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
||
| UT-027 | NullBackend set volume | DR-004 | Done |
|
||
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
||
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
||
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
||
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
||
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
||
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
||
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
||
| UT-035 | Download event completed serialization | DR-015 | Done |
|
||
| UT-036 | Download event failed serialization | DR-015 | Done |
|
||
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
||
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
||
| UT-039 | Download manager creation | DR-015 | Done |
|
||
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
||
| UT-041 | Download info serialization | DR-015 | Done |
|
||
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
||
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
||
| UT-044 | Offline item serialization | DR-017 | Done |
|
||
| UT-045 | Smart cache default config | DR-015 | Done |
|
||
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
||
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
||
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
||
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
|
||
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
|
||
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
|
||
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
|
||
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
|
||
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
|
||
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done |
|
||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
||
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
||
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||
| UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
|
||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
||
| 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 | Done |
|
||
| 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 |
|
||
| UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done |
|
||
| UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done |
|
||
| UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done |
|
||
| UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done |
|
||
| UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done |
|
||
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
|
||
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
|
||
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
|
||
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
|
||
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
|
||
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
|
||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
|
||
| UT-086 | A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
|
||
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
|
||
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
|
||
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
|
||
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done |
|
||
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
|
||
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
|
||
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
|
||
|
||
### Integration Tests
|
||
|
||
| Test ID | Test Description | Traces To | Status |
|
||
|---------|-----------------|-----------|--------|
|
||
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
||
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
||
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
||
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
||
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
||
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
||
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | 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-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 | Done |
|
||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
||
|
||
---
|
||
|
||
## 5. Technical Debt
|
||
|
||
### Linux Keyring Integration Workaround
|
||
|
||
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||
|
||
**Symptoms**:
|
||
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
||
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
||
- Session restoration fails on app restart even though credentials exist
|
||
|
||
**Root Cause**:
|
||
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
||
|
||
**Current Workaround**:
|
||
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
||
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
||
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
||
- **Delete**: `secret-tool clear service <service> username <username>`
|
||
|
||
**Implementation**:
|
||
See [src-tauri/src/credentials.rs](../src-tauri/src/credentials.rs) for the
|
||
Linux-specific `secret-tool` save/get/delete paths.
|
||
|
||
**Future Fix**:
|
||
- Monitor `keyring-rs` for bug fixes in future versions
|
||
- Consider alternative secure storage libraries
|
||
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
||
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
||
|
||
**Impact**:
|
||
- Low - The workaround is functionally equivalent to proper keyring integration
|
||
- Credentials are stored securely in the system keyring
|
||
- Session restoration works correctly
|
||
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
||
|
||
**Dependencies**:
|
||
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
||
- Already available on most Linux distributions by default
|
||
|
||
---
|
||
|
||
### Platform Playback Backend Parity (Linux vs Android)
|
||
|
||
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
||
|
||
**Symptoms**:
|
||
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
||
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
||
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
||
|
||
**Root Cause**:
|
||
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
||
|
||
**Affected Files**:
|
||
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
||
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
|
||
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
||
|
||
**Feature Parity Matrix**:
|
||
|
||
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
||
|---------|-------------|---------------------|--------|
|
||
| Basic playback | ✅ | ✅ | Parity |
|
||
| Volume control | ✅ | ✅ | Parity |
|
||
| Seek | ✅ | ✅ | Parity |
|
||
| Crossfade | ❌ | ❌ | Not implemented (blocked on MPV) |
|
||
| Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification |
|
||
| Volume normalization | ✅ | ⚠️ | Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification |
|
||
| Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification |
|
||
| Position updates | 250ms | On-demand | Inconsistent |
|
||
|
||
**Status** (see docs/specs/android-audio-settings-parity.md):
|
||
1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
|
||
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
|
||
3. ✅ Volume normalization via `LoudnessEnhancer`
|
||
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands
|
||
resampled onto the device's band centres
|
||
5. ⬜ **Not yet verified on a physical device** — the EQ/normalization effects
|
||
depend on device-specific `AudioEffect` availability and band layouts
|
||
6. ⬜ Flip the trait's `set_audio_settings` default from `Ok(())` to
|
||
`Err(not_implemented())` so a backend that omits it fails loudly instead of
|
||
silently reporting success. Deferred until (5) confirms the Android path works
|
||
7. ⬜ Standardize position update frequency across platforms
|
||
|
||
Crossfade is deliberately absent: it is unimplemented on every platform and
|
||
architecturally blocked on MPV, so building it on Android alone would invert the
|
||
parity gap. (The previously suggested `ConcatenatingMediaSource` is also
|
||
deprecated in current Media3.)
|
||
|
||
**Impact**:
|
||
- Medium - Android users lack audio enhancement features advertised in requirements
|
||
- User experience differs between platforms
|
||
- UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now
|
||
implemented on Android as well as Linux, pending on-device verification
|
||
- UR-031 (Crossfade) works nowhere — see DR-034
|
||
|
||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||
|
||
---
|
||
|
||
### Frontend Playback Code Duplication
|
||
|
||
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
||
|
||
**Symptoms**:
|
||
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
||
- Same `$derived` state merging logic for local/remote playback in both components
|
||
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
||
|
||
**Affected Files**:
|
||
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
||
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
||
|
||
**Duplicated Code**:
|
||
```typescript
|
||
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
||
handlePlayPause(), handleNext(), handlePrevious(),
|
||
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
||
|
||
// These derived states use identical logic:
|
||
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
||
```
|
||
|
||
**Future Fix**:
|
||
1. Create `src/lib/utils/playbackUnits.ts`:
|
||
```typescript
|
||
export const TICKS_PER_SECOND = 10_000_000;
|
||
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
||
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
||
```
|
||
|
||
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
||
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
||
- Single source of truth for merged local/remote state
|
||
|
||
3. Simplify handler wrappers using a utility:
|
||
```typescript
|
||
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
||
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
||
```
|
||
|
||
**Impact**:
|
||
- Low - Code works correctly but violates DRY principle
|
||
- Maintenance burden when logic needs to change
|
||
- Risk of handlers diverging over time
|
||
|
||
**Traces To**: DR-009
|