Files
jellytau/docs/requirements.md
T
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00

137 KiB
Raw Blame History

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
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
UR-065 Search answers from a locally indexed copy of the library, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screen High Implemented
UR-066 The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on High Done
UR-067 Favourited media can be found again. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing reads Medium Done
UR-068 Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playing Medium Done
UR-069 Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it Medium Done
UR-070 Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file Medium Proposed
UR-071 Media the viewer is watching can be kept, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again Medium Proposed
UR-073 Watched state is something the viewer can set, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable Medium Done
UR-072 Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it 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)
IR-030 Scheduled full-catalog crawl of every library (Recursive=true, paged) feeding the local index, driven by a Rust background task and the ConnectivityMonitor reconnect signal rather than by the frontend Storage UR-065 Implemented
IR-031 Android WindowInsets bridge: an OnApplyWindowInsetsListener on the decor view reports `systemBars() displayCutout()in CSS pixels, pushed into the WebView asjt-insetCSS custom properties plus ajellytau-insets-changedevent, and pullable via theAndroidInsets` JS bridge Platform UR-066
IR-032 Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable /Videos/{id}/stream.mp4 endpoint; plus per-platform read-through caching hooks (ExoPlayer CacheDataSource, mpv stream-record) for direct-play sessions only Storage UR-071 Proposed

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
JA-033 Query favourite items (Filters=IsFavorite, recursive, scoped by item type) Items UR-067 Done
JA-034 Read UserData (favourite, played, resume position) from item responses UserData UR-069 Done
JA-035 Mark item played (POST /Users/{userId}/PlayedItems/{itemId}) UserData UR-025 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_catalogINCLUDE_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 SeasonSectionEpisodeRow, 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-108 The instant (cache) leg of repository_search searches the synced catalog, not just downloads. OfflineRepository::search replaces its downloaded_items CTE with the available_items CTE get_items already uses — the same downloads branches plus a synced_at IS NOT NULL branch gated on the same include_catalog_browse() flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The include_item_types filter is switched from string interpolation to bound parameters, as SearchOptions is settable from the frontend and not only from SearchScope Backend UR-065 Implemented
DR-109 Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and last_catalog_sync (already persisted to app_settings, previously read only for a UI hint) is older than CATALOG_INDEX_TTL (6 h); the ConnectivityMonitor reconnect signal re-evaluates the same condition immediately. An AtomicBool prevents concurrent passes, replacing offlineCatalog.ts's syncInProgress — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. RepositoryManager gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case catalog-index-event Backend UR-065 Implemented
DR-110 Index hygiene. save_to_cache switches from INSERT OR REPLACE INTO items to ON CONFLICT(id) DO UPDATE: REPLACE fires no AFTER DELETE trigger unless recursive_triggers is on (it is not — only foreign_keys and journal_mode are set), so items_ad never ran, and because items.id is a TEXT PRIMARY KEY each replacement also took a fresh rowid and appended a second items_fts entry — a duplicate index per sync, invisible in results but permanently degrading MATCH. The upsert preserves the rowid items_fts keys on and fires items_au; migration 021_rebuild_items_fts clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because items.parent_id is ON DELETE CASCADE and a partial crawl would cascade away a whole series Storage UR-065 Implemented
DR-111 The index covers what the result groups render: CATALOG_ITEM_TYPES gains MusicArtist and Playlist, and migration 022_people_fts adds a people_fts virtual table over the existing people table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as items_fts. OfflineRepository::search UNIONs people_fts matches in as Person items when the resolved scope admits them — i.e. SearchScope::All, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server leg Storage UR-065, UR-060 Implemented
DR-112 Safe-area insets come from native, not from env() alone. env(safe-area-inset-*) is 0px without viewport-fit=cover (missing from app.html, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the display cutout — never the status bar or navigation bar. Since enableEdgeToEdge() plus targetSdk 36 make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. WindowInsetsBridge reads the real insets and publishes jt-inset custom properties; app.css folds them with env() via max() into --safe-*, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (shellReservesBottomInset hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's fitsSystemWindows=true — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed UI UR-066 Done
DR-113 MediaItem.user_data is populated from the server instead of being hardcoded None. JellyfinItem gains a UserData field (#[serde(alias = "UserData")] → the existing UserData type) and to_media_item maps it, so every list and detail response carries favourite/played/resume state. UserData is named explicitly in the Fields= list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track storageGetPlaybackProgress fetch is the only way to colour one heart Repository UR-069 Done
DR-114 Server favourite state is mirrored into the local user_data table by OfflineRepository::save_to_cache — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries pending_sync = 0 and is guarded by WHERE user_data.pending_sync = 0, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed Storage UR-069 Done
DR-115 Cross-library favourites query: a get_favorites(scope, options) repository method plus the repository_get_favorites command. Online issues Filters=IsFavorite&Recursive=true with IncludeItemTypes expanded from SearchScope::item_types() in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads items ⨝ user_data (is_favorite = 1) under the same include_catalog_browse() gate as browsing; hybrid races cache against server like get_items — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than get_items because favourites span libraries and get_items is ParentId-shaped Repository UR-067 Done
DR-116 GetItemsOptions.favorites_only filters an existing library listing in place — online by appending Filters=IsFavorite, offline by joining user_data into the existing available_items CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already there Repository UR-067 Done
DR-117 The Favourites page (/library/favorites) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing LibraryViewTabs + LibraryGrid + MediaCard so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a SearchScope value and nothing else. Reached from the library overview and from "See all" on the home rows UI UR-067 Done
DR-118 Home carries favourite rows for movies, shows and music, loaded via repository_get_favorites per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rows UI UR-067 Done
DR-119 FavoriteButton is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a MediaCard artwork overlay — and a favorites store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is store override ?? item.userData?.isFavorite ?? false. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cards UI UR-068 Done
DR-120 Favourite toggles made while offline reach the server. A Rust drain, triggered by the ConnectivityMonitor offline→online transition, pushes every user_data row with pending_sync = 1 and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case favorites-changed event ({ itemIds }) so open views update — without it a favourite marked on another client appears only on the second visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused syncService.queueFavorite, which is deleted rather than left as a second queue Backend UR-069 Done
DR-121 Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one get_video_download_url already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates within HLS: returning stream.mp4 is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves to UI UR-070 Proposed
DR-122 The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file Playback UR-070 Proposed
DR-123 The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default original, the direct static copy) over the Range-capable /Videos/{id}/stream.mp4, unaffected by bitrate changes and completing into an ordinary downloads row so offline browsing and refresh_queue_local_sources pick it up unchanged. Prerequisite: downloaded video is currently never played locally — repository_get_video_stream_url goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for video Repository UR-071 In Progress
DR-124 Streamed bytes are kept only where they are the download artifact — a direct-play session. Android uses ExoPlayer SimpleCache/CacheDataSource keyed by item and media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv stream-record, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is not captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case instead Playback UR-071 Proposed
DR-125 A capture is promoted to a completed downloads row only when it covers the whole resource; partials stay evictable cache. A new downloads.source_rendition column records the negotiated quality/container/codec (NULL for the existing paths, which are always original) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an in-flight capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file Storage UR-071 Proposed
DR-126 Cache eviction only reclaims the temporary tier. evict_lru_async selected every completed download ordered by completed_at ASC with no download_source filter, so hitting the 10 GB storage limit deleted the oldest download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only COALESCE(download_source, 'user') = 'auto' rows; COALESCE rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting them Storage UR-071 Done
DR-127 A cache entry is a download with a shorter life: same downloads row and same file handling, distinguished by download_source = 'auto' plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent ('user') rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never move Storage UR-071 Done
DR-128 Audio-only playback of downloaded media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with video: no, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video Playback UR-071 Done
DR-129 A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) Phantom end — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports STATE_ENDED indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the absolute position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in STATE_ENDED and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) Recoverable error — music (/Audio/{id}/stream?Static=true) and video (/Videos/{id}/master.m3u8) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item in place (never via play_item, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has StartTimeTicks rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only Remote sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping Playback UR-040, UR-004 Done
DR-130 A backend's position and duration must survive the end of the file they describe. MPV exposes time-pos/duration as properties of the loaded file, so at EOF it unloads and both stop resolving — the accessors reported 0.0/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (stream-lavf-o, network-timeout) so ordinary blips never surface, and EndFile(ERROR) — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a recoverable error. Because MpvBackend is constructed before PlayerController exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into player_recover_stream, which keeps the decision in Rust (the same shape as PlaybackEndedplayer_on_playback_ended). Android reports errors it has already declined as unrecoverable, so the echo never asks twice Playback UR-004, UR-040 Done
DR-131 The offline mutation queue is drained. sync_queue had producers and no consumer: PlaybackReporter::queue_for_sync writes a row for every start/stop/mark-played that cannot reach the server, sync_mark_processing/_completed/_failed were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same connectivity:reconnected transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. update_progress replays as stopped at N rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (position_ticks from Rust, camelCase positionMs from the frontend helper). A failed row stays queued for the next reconnect; after MAX_SYNC_ATTEMPTS it is abandoned and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An unreachable server is not counted as an attempt at all — the row goes back to pending untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires MediaRepository::mark_played (JA-035) — the previous stand-in reported a stop at i64::MAX Backend UR-025, UR-002 Done
DR-132 The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending transfers and looked for on the Downloads page — which lists the downloads table and structurally cannot show sync_queue rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a LEFT JOIN items in sync_get_pending, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits sync-queue-changed so the badge updates on reconnect instead of lagging by up to one 10s poll UI UR-025 Done
DR-133 A downloaded file has exactly one on-disk path, and the row that names it is authoritative. downloads.file_path starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a completed row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol /data/user/0/app//data/user/0/app/videos/x.mp4; the webview reported MEDIA_ERR_SRC_NOT_SUPPORTED with NETWORK_NO_SOURCE, so every downloaded video failed to play while audio — which resolves the same column through Rust's resolve_local_media_path, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve Playback UR-071 Done
DR-134 The webview can actually fetch the local files it is handed. convertFileSrc rewrites a path to http://asset.localhost/… unconditionally, but Tauri only answers that origin when the protocol-asset cargo feature is compiled in and app.security.assetProtocol.enable is set — neither was, so every such URL reached a protocol with no handler and the webview reported NETWORK_NO_SOURCE. This silently defeated both offline video (<video src>) and the cached-thumbnail path in imageCache, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is $APPDATA/** — the storage root under which the database, downloads/ and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else Security UR-071 Done
DR-140 An audio track is pinned only when the user picked one. Jellyfin's MediaStream.Index is global across every stream in a media source, so index 0 is the video stream on virtually all files — yet AudioStreamIndex=0 was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the PlaybackInfo negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's DefaultAudioStreamIndex; an explicit selection from player_switch_audio_track is still carried through unchanged. On the static=true direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead Playback UR-004, UR-040 Done
DR-147 One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under /library/** and merely navigated to /search (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from ?q= against library.searchQuery on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on /search too (showHeaderSearch) and is the sole md+ input — the page's own input is md:hidden — and on that route it republishes the query into the URL with replaceState, so a whole session of typing costs one history entry. The page consumes that URL once per distinct value (seedFromSearchUrl against a non-reactive applied marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on /search with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route UI UR-049, UR-054 Done
DR-142 An episode has exactly one surface, and it is complete. Two divergent renderings existed: EpisodeFocusView (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare /library/<episodeId> page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row Play / Download / Favourite, series name and SxEy badge as links back to the series and to that season's anchor, then genres → cast → similar shows below the episode strip, never above it (DR-062). /library/<episodeId> redirects into it (episodeRedirectTarget, the same rule seasons follow under DR-103), and an episode with no seriesId renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a list query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise UI UR-048, UR-058 Done
DR-141 The device profile states how many channels the audio route can actually voice. MediaCodecList answers "can this device decode 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no MaxAudioChannels in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed AudioSink configuration (silence) or dialogue folded into surround channels that go nowhere. media3's AudioCapabilities.maxChannelCount for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has Playback UR-004 Done
DR-145 Video playback starts only once the app actually holds audio focus. Video manages focus by hand (handleAudioFocus=false, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: AUDIOFOCUS_REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly invites, and which means the system is withholding our audio until it calls back — and an outright REQUEST_FAILED were logged and then followed by playWhenReady = true. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the AUDIOFOCUS_GAIN callback; an explicit play() re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A LOSS clears the pending flag, so an unrelated later GAIN cannot start playback the user never asked for Playback UR-004 Done
DR-146 The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first isTrackSupported track and overrides to that, and clears setTrackTypeDisabled(TRACK_TYPE_AUDIO) because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log Playback UR-004 Done
DR-148 The video direct-play profile advertises only what the webview can decode. The audio codec list comes from MediaCodecList, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview <video> element (the interim override in VideoPlayer.svelte, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping /vendor/etc/media_codecs_dolby_audio.xml reports ac3,eac3, so Jellyfin direct-played an E-AC-3 track with static=true and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by capability, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. video_audio_codecs narrows the platform list to the webview-decodable set (aac,mp3,opus,vorbis,flac) for the video direct-play profile only — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims aac rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio Playback UR-004 Done
DR-149 The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a DirectPlayProfile's Container and VideoCodec — excluding either returns SupportsDirectPlay: false with TranscodeReasons=ContainerNotSupported / VideoCodecNotSupported — but ignores its AudioCodec, offering an E-AC-3 track for direct play against a profile listing only aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the codec nor a MaxAudioChannels: 2 against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — direct_play and needs_transcoding are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play Playback UR-004 Done
DR-150 Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the experimentalNativeVideo opt-in. Rust already reported use_html5_element: false on Android, but two frontend overrides discarded it — createAdapter() hardcoded "html5", and VideoPlayer.svelte forced useHtml5Element = true and stopped the native backend player_play_item had just started. The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (AndroidVideoSurface.setTransparent), and the page's html/body and app-shell background from CSS (data-native-video). Transparency is declared in tauri.android.conf.json rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app Playback UR-003, UR-004 Done (behind experimentalNativeVideo, default off)
DR-151 The player's video SurfaceView actually reaches the view hierarchy. JellyTauPlayer.setActivity() had zero callers, so currentActivity was always null and autoAttachSurface() returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. MainActivity.onCreate now supplies the reference, which also revives PiP on the video path: canEnterPip() gates on isVideoSurfaceAttached(), which had been permanently false Playback UR-003, UR-041 Done
DR-152 Platform playback facilities are reported by Rust, not sniffed from the user agent. webviewAudio.ts re-derived "does this platform have a native audio backend" by matching navigator.userAgent against android/linux — a second copy of the cfg! gate the backends are compiled under, free to drift from it. player_get_capabilities now returns usesWebviewAudio and supportsNativeVideo from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it Player UR-003, UR-005 Done
DR-153 The git tag is the single source of truth for a release version. The version lived in four files (package.json, tauri.conf.json, Cargo.toml, Cargo.lock) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. scripts/set-version.sh writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android versionCode is derived in the same place as 1000 + major*10000 + minor*100 + patch, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes ${GITHUB_REF#refs/tags/} unconditionally) falls back to git describe rather than failing a branch build Build - Done
DR-154 A watch position that cannot reach the server is queued, not dropped. sync_queue and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: HybridRepository::report_playback_stopped is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend catch whose own comment read "Server error - could queue, but for now just log". Both producers that would have queued it — PlaybackReporter::queue_for_sync in Rust and syncService.queuePlaybackProgress on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though user_data.pending_sync was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a report_playback_stopped row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is superseded in place rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only pending/failed rows are superseded, because an abandoned row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed queue write must not be reported as a lost position Backend UR-025, UR-002 Done
DR-155 A watch position set on another device reaches this one. The resume check reads the local user_data row and nothing else, but mirror_user_data — the only path by which server UserData lands in that table — mirrored is_favorite alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So playback_position_ticks was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever this device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same pending_sync = 0 conflict rule, so a local position still waiting to be pushed is never pulled backwards by a server that has not yet heard where we got to; COALESCE(excluded.x, user_data.x) means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: get_item — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (race_with_refresh, the reusable form of what get_items already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately Backend UR-025, UR-002 Done
DR-156 A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a flex-1 overflow-y-auto box that outlives the route inside it, which is what lets BottomUi be a flex sibling rather than a measured overlay — but the element therefore never remounts and its scrollTop survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores window scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. ScrollMemory (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in beforeNavigate and decides in afterNavigate: link/goto/form reset to the top, popstate restores that route's saved offset, and the initial enter is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the scrollContainer action on all three scrollers UI UR-072 Done
DR-160 Picture-in-picture works on the path that actually plays video. PiP shrinks the whole Activity, so canEnterPip demanded a native ExoPlayer SurfaceView be attached and rendering — isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached(). But the native path sits behind experimentalNativeVideo, which defaulted to off, so in the shipping configuration video played in the WebView's <video> element and all three conditions were false. enterPip bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing) — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because ExoPlayer.isPlaying is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView is the video: it must stay visible in PiP rather than be hidden (hideWebView is now gated on the native path — hiding it would leave an empty black window), and the play/pause RemoteAction has to reach the element, so the receiver dispatches jellytau-pip-play/jellytau-pip-pause DOM events instead of driving ExoPlayer. jellytau-pip-entered/-exited let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The <video> is deregistered on teardown so PiP is never offered over a video that has gone UI UR-041 Done (pending device verification)
DR-161 Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the UI into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what experimentalNativeVideo gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and VideoPlayer.scrubRegression.test.ts documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the flag-off interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification UI UR-041, UR-003 Needs device verification
DR-159 The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a relative stream — the audio-only URL is built with StartTimeTicks = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and background_audio_base holds the offset that turns one back into a real position. The base was a display-only correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly base seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told real base — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own seekToDefaultPosition/seekBack/seekForward bypassed the ForwardingPlayer wrapper entirely, reaching the real ExoPlayer — Util.handlePlayButtonAction seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts once, at the boundary: JellyTauPlayer's position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either nativeOnPositionUpdate or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently removed from claim_stream_resume, truncated_stream_resume_position and player_exit_background_audio, where adding it now double-counts, and the lockscreen's positionOffsetMs addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: seek_absolute is the new boundary for every outside seek, re-opening the stream at the requested position via resume_stream_at when a handoff is active — which is what onSeekTo had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. seekToDefaultPosition is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the play() that follows reaches it. Exit reads the position before clearing either base, or a tick landing in between hands back a relative one Player UR-040, UR-005, UR-025 Done (pending device verification)
DR-158 A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: mark_played (POST /PlayedItems) was reachable only from the sync drain replaying rows the reporter had queued, and clear_watch_history (DELETE /PlayedItems) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out online. Offline it does: storage_set_watched writes the item and its descendants (drawn from items by parent_id/album_id/season_id/series_id, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from storage_mark_played, which stays the single-item "this finished playing" path that increments play_count. Un-marking clears the resume position as well as the flag, matching the server. QueuedOp::MarkUnplayed gives the queue the missing direction — pushing as clear_watch_history — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike ClearHistoryButton it does not confirm, and it holds an optimistic state because the caller's watched prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) UI UR-073 Done
DR-157 Full-screen video on Android actually goes full screen. toggleFullscreen called document.documentElement.requestFullscreen() and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because enableEdgeToEdge() is called in onCreate and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about reserving space for the bars) here the bars should not be there at all. ImmersiveModeBridge hides them via WindowInsetsControllerCompat with BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the AndroidImmersive bridge and posted to the main thread, since @JavascriptInterface methods arrive on a WebView binder thread. requestFullscreen() is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called document.exitFullscreen() directly, bypassing the flag and the bars), and onDestroy — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The --jt-inset-* properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and WindowInsetsBridge republishes them UI UR-066 Done
DR-143 Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only while a query runs, but no library surface re-queried when its inputs changed: useServerReachabilityReload fires only on the offline → online transition, and GenericMediaListPage, GenericGenreBrowser and the favourites page never even called its checkServerReachability. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — MediaCard.isServerOnly is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. catalogFilterVersion is the refetch signal: pushCatalogVisibility now awaits set_show_server_catalog and bumps the version only after the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears lastIncludeCatalog instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. useOfflineFilterReload subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the /library/[id] detail page UI UR-052 Done
DR-135 A download's media type comes from the item, not a default. download_item — the path a media card uses to queue an item while offline — never records media_type, and the reconnect resolver read that NULL as 'audio', so a movie queued from a card had its URL resolved by get_audio_stream_url. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own item_type (VIDEO_ITEM_TYPES in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit media_type on the row still wins Downloads UR-071, UR-052 Done
DR-136 Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no media_type, but a video item — so on reconnect they are reset to pending with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit media_type and genuine audio downloads are left untouched Downloads UR-071 Done
DR-137 Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's asset protocol answers a request carrying no Range header by reading the whole file into memory, and only advertises Accept-Ranges: bytes from inside its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with PIPELINE_ERROR_READ after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on 127.0.0.1 is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards Range to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because loopback is shared between apps on Android, the server binds 127.0.0.1 only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here Playback UR-071 Done
DR-138 Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set usesCleartextTraffic="false", so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — <video> failed in the same millisecond as loadstart, with NETWORK_NO_SOURCE and no server-side log at all, which is why it looked identical to a missing file. A network-security-config resource permits cleartext for 127.0.0.1 only and keeps base-config cleartextTrafficPermitted="false", so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. sync-android-sources.sh also had to learn to copy res/xml, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly Security UR-071 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, DR-129
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, DR-131, DR-132
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, DR-129, DR-130, DR-159
UR-041 IR-026 DR-053, DR-160, DR-161
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, DR-142
UR-049 IR-010 DR-063, DR-064, DR-065, DR-147
UR-050 - DR-066, DR-067
UR-051 - DR-068, DR-069, DR-070
UR-052 IR-027 DR-078, DR-079, DR-080, DR-143
UR-053 IR-029 DR-074
UR-054 - DR-075, DR-076, DR-077, DR-147
UR-055 - DR-081, DR-082, DR-083, DR-084
UR-056 - DR-085
UR-057 - DR-086
UR-058 - DR-087, DR-142
UR-060 - DR-090, DR-091, DR-111
UR-061 - DR-092
UR-062 - DR-101, DR-102, DR-103, DR-104, DR-107
UR-063 - DR-105
UR-064 - DR-106
UR-065 IR-030 DR-108, DR-109, DR-110, DR-111
UR-066 IR-031 DR-112, DR-157
UR-067 - DR-115, DR-116, DR-117, DR-118
UR-068 - DR-119
UR-069 - DR-113, DR-114, DR-120
UR-070 - DR-121, DR-122
UR-071 IR-032 DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138
UR-072 - DR-156
UR-073 - DR-158

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; 23 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
UT-094 parseNativeInsets accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting NaNpx (which would invalidate the whole padding declaration) DR-112 Done
UT-095 safeAreaCssVars/applySafeAreaInsets emit px-suffixed jt-inset custom properties for all four edges DR-112 Done
UT-096 readNativeInsets returns null with no bridge and survives a stale WebView proxy (missing or throwing get) instead of throwing out of layout init IR-031, DR-112 Done
UT-097 initSafeArea primes the document on start, re-applies on jellytau-insets-changed (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so env() still wins on iOS/desktop IR-031, DR-112 Done
UT-098 shellReservesBottomInset gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded DR-112 Done
UT-099 A Jellyfin item payload carrying UserData.IsFavorite maps to MediaItem.user_data.is_favorite DR-113, JA-034 Done
UT-100 OnlineRepository::get_favorites builds Filters=IsFavorite + Recursive=true + the scope's IncludeItemTypes, and omits the type filter entirely for SearchScope::All DR-115, JA-033 Done
UT-101 OfflineRepository::get_favorites returns only is_favorite = 1 rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off DR-115 Done
UT-102 The save_to_cache favourite mirror does not overwrite a row with pending_sync = 1 DR-114 Done
UT-103 The reconnect drain pushes pending favourites, clears pending_sync, and leaves failed rows pending DR-120 Done
UT-104 get_items with favorites_only filters online (endpoint) and offline (SQL) DR-116 Done
UT-105 favorites store precedence: override beats userData.isFavorite beats false DR-119 Done
UT-106 Un-favouriting removes an item from a favourites listing view DR-117, DR-119 Done
UT-107 The hybrid background refresh emits favorites-changed only for ids whose favourite state actually flipped DR-120 Done
UT-109 Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off DR-108 Done
UT-110 Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results DR-108 Done
UT-111 FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring DR-108 Done
UT-112 Repeated catalog passes leave one items_fts entry per item, not one per pass DR-110 Done
UT-113 The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server DR-110 Done
UT-114 Cached people are reachable from unscoped search and excluded from scoped search DR-111 Done
UT-115 Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not DR-109 Done
UT-116 resolve_local_media_path returns a completed download's file, and None for an in-progress download, a row whose file has been deleted, or an unknown item DR-123 Done
UT-118 resolveVideoSource prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path DR-123 Done
UT-119 The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync DR-128 Done
UT-120 Expiry reclaim takes only expired temporary entries: derived from completed_at+TTL, honouring an expires_at override, never a user download, and disabled by a zero TTL DR-127 Done
UT-108 LRU eviction reclaims only 'auto' downloads and never a user's own, even when the user's is the oldest DR-126 Done
UT-117 A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded DR-129 Done
UT-124 downloadedFilePath leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative DR-133 Done
UT-125 A NULL media_type resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit media_type overrides the item DR-135 Done
UT-126 Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone DR-136 Done
UT-127 The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes DR-137 Done
UT-121 An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's DR-130 Done
UT-122 The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after MAX_SYNC_ATTEMPTS, ignores other users' rows, and parses both payload dialects DR-131 Done
UT-123 Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first DR-132 Done
UT-130 Video and background-audio stream URLs omit AudioStreamIndex when no track was chosen, and carry the exact index when one was DR-140 Done
UT-131 The Episode Focus View hero offers a download control DR-142 Done
UT-132 The series name links to the series and the SxEy badge to that season's anchor DR-142 Done
UT-133 Cast renders below the "More Episodes" strip, never above it DR-062, DR-142 Done
UT-134 The episode strip is hidden when the episode has no siblings DR-142 Done
UT-135 An episode with no seriesId still renders the Focus View, with title, Play and download DR-142 Done
UT-136 episodeRedirectTarget sends a bare episode page into its series' Focus View, and returns null with no series DR-142 Done
UT-137 Going offline with the toggle off pushes the closed gate and bumps catalogFilterVersion DR-143 Done
UT-138 The version bumps only after set_show_server_catalog resolves, never before DR-143 Done
UT-139 A failed visibility push is retried on the next identical transition rather than latched DR-143 Done
UT-140 useOfflineFilterReload skips the value a page already loaded under and reloads on each later change DR-143 Done
UT-141 The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word DR-141 Done
UT-148 Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed DR-149 Done
UT-149 createAdapter returns the native adapter only when Rust reports native AND experimentalNativeVideo is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 DR-150 Done
UT-150 set-version.sh stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix DR-153 Done
UT-151 An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report DR-154 Done
UT-152 Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards DR-155 Done
UT-155 A seek during a background-audio handoff re-opens the stream at the requested absolute position (StartTimeTicks) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base DR-159 Done
UT-154 mark_unplayed parses to QueuedOp::MarkUnplayed and is rejected without an item id, and a queued un-mark drains to the server as clear_watch_history DR-158 Done
UT-153 Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone DR-156 Done
UT-142 The audio codecs offered for video direct play: a Dolby device's real MediaCodecList output drops ac3/eac3, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims aac, and stray spacing or casing does not decide whether the user gets sound DR-148 Done
UT-143 Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected UR-020, DR-023 Done
UT-144 VideoPlayer actually renders <track kind="subtitles"> children carrying data-stream-index, with no default attribute and no async getSubtitleUrl() bound to src UR-020, DR-023 Done
UT-145 The frontend's subtitle payload survives the IPC hop: a camelCase PlayItemRequest carrying subtitles deserializes, create_media_item lands them on MediaItem.subtitles in the order sent, and a request without the field still defaults to empty UR-020, IR-016 Done
UT-146 The subtitle JSON serialized across the JNI boundary uses the keys JellyTauPlayer.load() reads — url, language, label and mime_type, never mimeType UR-020, IR-016, JA-008 Done
UT-147 The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps mime_type and stream order, playerPlayItem actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number UR-020, IR-016 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 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:

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:

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:

  1. 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;
    
  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:

    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