Both still described the 300ms deferred-tap design that DR-098 replaced with immediate action, so the generated release notes advertised behaviour the code no longer has.
57 KiB
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/. For development workflows, see the README and 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) | 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) | 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) | 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) | 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) | 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) | 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) | 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) | 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 and §5B.1) | 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 |
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-097 | Transport authority (play/pause/toggle) lives in Rust for webview-rendered media, not just native. The controller tracks the state the HTML5 element reports (html5_playing, fed by report_html5_state, which now stores rather than only re-emitting); play/pause/toggle_playback consult it and drive the element by emitting a ControlCommand that playerEvents.handleControlCommand executes against the active adapter. A stopped/idle report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: adapter.toggle() previously decided play-vs-pause by reading el.paused off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read different values, performed opposing actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered readyState=4 networkState=1 element). Same "backend decides, adapter executes the primitive" split as player_seek_video |
Player | UR-005 | Done |
| DR-096 | Html5PlayerAdapter.play() is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one element.play() instead of stacking calls, and an AbortError ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to host.onError. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused |
Player | UR-005 | Done |
| DR-095 | Seek targets clamp strictly inside the media (clampSeekTarget, END_SEEK_MARGIN_SECONDS = 6 s ≈ one HLS segment) instead of to the exact duration. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip resolveSeekTarget and the seek-bar drag, whose range input max is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start |
UI | UR-061 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from requirements.md at run time rather than hardcoded literals: countDefinedRequirements counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; computeCoverage reports the intersection of traced and defined IDs so an ID traced in code but absent from requirements.md is surfaced as orphaned instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and bun run traces:coverage share this computation and fail on both a sub-threshold and an impossible >100% result |
Tooling | - | Done |
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 |
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-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own play/pause/toggle — the webview must not decide play-vs-pause from the DOM |
DR-097 | Done |
Integration Tests
| 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-rslibrary fails withNoEntryerror - 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 for the
Linux-specific secret-tool save/get/delete paths.
Future Fix:
- Monitor
keyring-rsfor 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-rsAPI
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-rsimplementation
Dependencies:
- Requires
secret-toolto be installed on Linux systems (part oflibsecret-toolspackage) - 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: globalOnceLockstatics)
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 - Trait with default empty implementations
- src-tauri/src/player/mpv_backend.rs - Full audio settings support
- 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):
- ✅
set_audio_settings()implemented inExoPlayerBackend(JSON over JNI) - ✅ Gapless via ExoPlayer's
pauseAtEndOfMediaItems - ✅ Volume normalization via
LoudnessEnhancer - ✅ Equalizer via
android.media.audiofx.Equalizer, canonical 10 bands resampled onto the device's band centres - ⬜ Not yet verified on a physical device — the EQ/normalization effects
depend on device-specific
AudioEffectavailability and band layouts - ⬜ Flip the trait's
set_audio_settingsdefault fromOk(())toErr(not_implemented())so a backend that omits it fails loudly instead of silently reporting success. Deferred until (5) confirms the Android path works - ⬜ 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
$derivedstate 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 - Duplicate handlers
- src/lib/components/player/MiniPlayer.svelte - Duplicate handlers
- src/lib/services/playbackControl.ts - Position conversion
- src/lib/stores/playbackMode.ts - Position conversion
- src/lib/services/playbackReporting.ts - Position conversion
Duplicated Code:
// 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:
-
Create
src/lib/utils/playbackUnits.ts: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; -
Create
src/lib/composables/useMergedPlaybackState.svelte.ts:- Export
displayMedia,displayIsPlaying,displayPosition,displayDuration - Single source of truth for merged local/remote state
- Export
-
Simplify handler wrappers using a utility:
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