# Svelte Frontend Architecture ## Store Structure **Location**: `src/lib/stores/` ```mermaid flowchart TB subgraph Stores subgraph auth["auth.ts"] AuthState["AuthState
- user
- serverUrl
- token
- isLoading"] end subgraph playerStore["player.ts"] PlayerStoreState["PlayerState
- kind
- media
- position
- duration"] end subgraph queueStore["queue.ts"] QueueState["QueueState
- items
- index
- shuffle
- repeat"] end subgraph libraryStore["library.ts"] LibraryState["LibraryState
- libraries
- items
- loading"] end subgraph Derived["Derived Stores"] DerivedList["isAuthenticated, currentUser
isPlaying, isPaused, currentMedia
hasNext, hasPrevious, isShuffle
libraryItems, isLibraryLoading"] end end ``` ## Music Library Architecture **Category-Based Navigation:** JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types. **Route Structure:** ```mermaid graph TD Music["/library/music
(Landing page with category cards)"] Tracks["Tracks
(List view only)"] Artists["Artists
(Grid view)"] Albums["Albums
(Grid view)"] Playlists["Playlists
(Grid view)"] Genres["Genres
(Genre browser)"] Music --> Tracks Music --> Artists Music --> Albums Music --> Playlists Music --> Genres ``` **View Enforcement:** Ordinal content (where position carries meaning) is always a list. Everything else honours the user's persisted grid/list preference — see [ux-flows.md §5A.2](../ux-flows.md). | Content Type | View Mode | Toggle Visible | Component Used | |--------------|-----------|----------------|----------------| | Tracks | List (forced — ordinal) | No | `TrackList` | | Artists | User preference | Yes | `LibraryGrid` | | Albums | User preference | Yes | `LibraryGrid` | | Playlists | User preference | Yes | `LibraryGrid` | | Genres | User preference (both levels) | Yes | `LibraryGrid` | | Album Detail Tracks | List (forced — ordinal) | No | `TrackList` | | Season Episodes | List (forced — ordinal) | No | `SeasonSection` | **TrackList Component:** The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format: - **No Thumbnails**: Track numbers only (transform to play button on hover) - **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration - **Mobile Layout**: Compact rows with track number and metadata - **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility - **Click Behavior**: Clicking a track plays it and queues all filtered tracks **Example Usage:** ```svelte ``` **LibraryGrid view mode:** `LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`) and renders `LibraryListView` or the card grid accordingly. The `showViewToggle` prop controls whether the toggle buttons appear in the page header; the grid itself always follows the stored preference. A `forceGrid` prop previously existed to pin pages to grid regardless of preference. No caller ever passed it, so it was removed — pages that were documented as "forced grid" have in practice always honoured the toggle. ## Playback Reporting Service **Location**: `src/lib/services/playbackReporting.ts` The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables: - Offline "Continue Watching" functionality - Sync queue for when network is unavailable - Consistent progress across app restarts ```mermaid sequenceDiagram participant VideoPlayer participant PlaybackService as playbackReporting.ts participant LocalDB as Local SQLite
(Tauri Commands) participant Jellyfin as Jellyfin Server VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position) par Local Storage (always works) PlaybackService->>LocalDB: invoke("storage_update_playback_progress") LocalDB-->>PlaybackService: Ok (pending_sync = true) and Server Sync (if online) PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress Jellyfin-->>PlaybackService: Ok PlaybackService->>LocalDB: invoke("storage_mark_synced") end ``` **Service Functions:** - `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins - `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s) - `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends **Tauri Commands:** | Command | Description | |---------|-------------| | `storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`) | | `storage_mark_played` | Mark item as played, increment play count | | `storage_get_playback_progress` | Get stored progress for an item | | `storage_mark_synced` | Clear `pending_sync` flag after successful server sync | **Database Schema Notes:** - The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT) - Playback progress can be tracked even when the full item metadata hasn't been downloaded yet **Resume Playback Feature:** - When loading media for playback, the app checks local database for saved progress - If progress exists (>30 seconds watched and <90% complete), shows resume dialog - User can choose to "Resume" from saved position or "Start from Beginning" - For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point - For audio: Seeks to resume position after loading via MPV backend - Implemented in `src/routes/player/[id]/+page.svelte` ## Repository Architecture (Rust-Based) **Location**: `src-tauri/src/repository/` ```mermaid classDiagram class MediaRepository { <> +get_libraries() +get_items(parent_id, options) +get_item(item_id) +search(query, options) +get_latest_items(parent_id, limit) +get_resume_items(parent_id, limit) +get_next_up_episodes(series_id, limit) +get_genres(parent_id) +get_playback_info(item_id) +report_playback_start(item_id, position_ticks) +report_playback_progress(item_id, position_ticks, is_paused) +report_playback_stopped(item_id, position_ticks) +mark_favorite(item_id) +unmark_favorite(item_id) +get_person(person_id) +get_items_by_person(person_id, options) +get_image_url(item_id, image_type, options) +create_playlist(name, item_ids) +delete_playlist(playlist_id) +rename_playlist(playlist_id, name) +get_playlist_items(playlist_id) +add_to_playlist(playlist_id, item_ids) +remove_from_playlist(playlist_id, entry_ids) +move_playlist_item(playlist_id, item_id, new_index) } class OnlineRepository { -http_client: Arc~HttpClient~ -server_url: String -user_id: String -access_token: String -connectivity: Option~Arc~ConnectivityMonitor~~ +new() +with_connectivity() -report_outcome() } class OfflineRepository { -db_service: Arc~DatabaseService~ -server_id: String -user_id: String +new() +cache_library() +cache_items() +cache_item() } class HybridRepository { -online: Arc~OnlineRepository~ -offline: Arc~OfflineRepository~ +new() -parallel_race() -cache_with_timeout() } MediaRepository <|.. OnlineRepository MediaRepository <|.. OfflineRepository MediaRepository <|.. HybridRepository HybridRepository --> OnlineRepository HybridRepository --> OfflineRepository ``` **Key Implementation Details:** 1. **Cache-First Racing Strategy** (`hybrid.rs`): - Runs cache (SQLite) and server (HTTP) queries in parallel - Cache has 100ms timeout - Returns cache result if it has meaningful content - Falls back to server result otherwise - Background cache updates planned - **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck. 2. **Handle-Based Resource Management** (`repository.rs` commands): ```rust // Frontend creates repository with UUID handle repository_create(server_url, user_id, access_token, server_id) -> String (UUID) // All operations use handle for identification repository_get_libraries(handle: String) -> Vec repository_get_items(handle: String, ...) -> SearchResult // Cleanup when done repository_destroy(handle: String) ``` - Enables multiple concurrent repository instances - Thread-safe with `Arc>>>` - No global state conflicts 3. **Frontend API Layer** (`src/lib/api/repository-client.ts`): - Thin TypeScript wrapper over Rust commands - Maintains handle throughout session - All methods: `invoke("repository_operation", { handle, ...args })` - ~100 lines (down from 1061 lines) ## Playback Mode System **Location**: `src-tauri/src/playback_mode/mod.rs` The playback mode system manages transitions between local device playback and remote Jellyfin session control: ```rust pub enum PlaybackMode { Local, // Playing on local device Remote { session_id: String }, // Controlling remote session Idle, // Not playing } pub struct PlaybackModeManager { current_mode: PlaybackMode, player_controller: Arc>, jellyfin_client: Arc, } ``` **Key Operations:** 1. **Transfer to Remote** (`transfer_to_remote(session_id)`): ```mermaid sequenceDiagram participant UI participant Manager as PlaybackModeManager participant Player as PlayerController participant Jellyfin as Jellyfin API UI->>Manager: transfer_to_remote(session_id) Manager->>Player: Extract queue items Manager->>Manager: Get Jellyfin IDs from queue Manager->>Jellyfin: POST /Sessions/{id}/Playing Note over Jellyfin: Start playback with queue Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek Note over Jellyfin: Seek to current position Manager->>Player: Stop local playback Manager->>Manager: Set mode to Remote ``` 2. **Transfer to Local** (`transfer_to_local(item_id, position_ticks)`): - Stops remote session playback - Prepares local player to resume - Sets mode to Local **Tauri Commands** (`playback_mode.rs`): - `playback_mode_get_current()` -> Returns current PlaybackMode - `playback_mode_transfer_to_remote(session_id)` -> Async transfer - `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back - `playback_mode_is_transferring()` -> Check transfer state - `playback_mode_set(mode)` -> Direct mode setting **Frontend Store** (`src/lib/stores/playbackMode.ts`): - Thin wrapper calling Rust commands - Maintains UI state (isTransferring, transferError) - Listens to mode change events from Rust ## Database Service Abstraction **Location**: `src-tauri/src/storage/db_service.rs` Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime: ```rust #[async_trait] pub trait DatabaseService: Send + Sync { async fn execute(&self, query: Query) -> Result; async fn execute_batch(&self, queries: Vec) -> Result<(), DatabaseError>; async fn query_one(&self, query: Query, mapper: F) -> Result where F: FnOnce(&Row) -> Result + Send + 'static; async fn query_optional(&self, query: Query, mapper: F) -> Result, DatabaseError> where F: FnOnce(&Row) -> Result + Send + 'static; async fn query_many(&self, query: Query, mapper: F) -> Result, DatabaseError> where F: Fn(&Row) -> Result + Send + 'static; async fn transaction(&self, f: F) -> Result where F: FnOnce(Transaction) -> Result + Send + 'static; } pub struct RusqliteService { connection: Arc>, } impl DatabaseService for RusqliteService { async fn execute(&self, query: Query) -> Result { let conn = self.connection.clone(); tokio::task::spawn_blocking(move || { // Execute query on blocking thread pool }).await? } // ... other methods use spawn_blocking } ``` **Key Benefits:** - **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking` - **Type Safety**: `QueryParam` enum prevents SQL injection - **Future Proof**: Easy to swap to native async DB (tokio-rusqlite) - **Testable**: Can mock DatabaseService for tests **Usage Pattern:** ```rust // Before (blocking - causes UI freeze) let conn = database.connection(); let conn = conn.lock().unwrap(); // BLOCKS conn.query_row(...) // BLOCKS // After (async - no freezing) let db_service = database.service(); let query = Query::with_params("SELECT ...", vec![...]); db_service.query_one(query, |row| {...}).await // spawn_blocking internally ``` ## Component Hierarchy ```mermaid graph TD subgraph Routes["Routes (src/routes/)"] LoginPage["Login Page"] LibLayout["Library Layout"] LibDetail["Album/Series Detail"] MusicCategory["Music Category Landing"] Tracks["Tracks"] Artists["Artists"] Albums["Albums"] Playlists["Playlists"] Genres["Genres"] Downloads["Downloads Page"] Settings["Settings Page"] PlayerPage["Player Page"] end subgraph PlayerComps["Player Components"] AudioPlayer["AudioPlayer"] VideoPlayer["VideoPlayer"] MiniPlayer["MiniPlayer"] Controls["Controls"] Queue["Queue"] SleepTimerModal["SleepTimerModal"] SleepTimerIndicator["SleepTimerIndicator"] end subgraph SessionComps["Sessions Components"] CastButton["CastButton"] SessionModal["SessionPickerModal"] SessionCard["SessionCard"] SessionsList["SessionsList"] RemoteControls["RemoteControls"] end subgraph LibraryComps["Library Components"] LibGrid["LibraryGrid"] LibListView["LibraryListView"] TrackList["TrackList"] PlaylistDetail["PlaylistDetailView"] DownloadBtn["DownloadButton"] MediaCard["MediaCard"] end subgraph PlaylistComps["Playlist Components"] CreatePlaylistModal["CreatePlaylistModal"] AddToPlaylistModal["AddToPlaylistModal"] end subgraph CommonComps["Common Components"] ScrollPicker["ScrollPicker"] end subgraph OtherComps["Other Components"] Search["Search"] FavoriteBtn["FavoriteButton"] DownloadItem["DownloadItem"] end LibLayout --> PlayerComps LibLayout --> LibDetail MusicCategory --> Tracks MusicCategory --> Artists MusicCategory --> Albums MusicCategory --> Playlists MusicCategory --> Genres LibDetail --> LibraryComps Playlists --> PlaylistComps Playlists --> PlaylistDetail Downloads --> DownloadItem PlayerPage --> PlayerComps MiniPlayer --> CastButton CastButton --> SessionModal SleepTimerModal --> ScrollPicker PlayerComps --> LibraryComps ``` ## MiniPlayer Behavior **Location**: `src/lib/components/player/MiniPlayer.svelte` The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls. **Touch Gesture Handling:** The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page): ```typescript function handleTouchStart(e: TouchEvent) { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; touchEndX = touchStartX; // Initialize to start position touchEndY = touchStartY; // Prevents taps being treated as swipes isSwiping = true; } ``` **Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page. **Skip Button State:** The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend. ## Sleep Timer Architecture **Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs` **TRACES**: UR-026 | DR-029 The sleep timer supports three modes for stopping playback: ```rust #[serde(tag = "kind", rename_all = "camelCase")] pub enum SleepTimerMode { Off, Time { end_time: i64 }, // Unix timestamp in milliseconds EndOfTrack, // Stop after current track/episode Episodes { remaining: u32 }, // Stop after N more episodes } ``` **Timer Modes:** | Mode | Trigger | How It Stops | |------|---------|-------------| | Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` | | EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` | | Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 | **Time-Based Timer Flow:** ```mermaid sequenceDiagram participant UI as SleepTimerModal participant Store as sleepTimer store participant Rust as PlayerController participant Thread as Timer Thread participant Backend as PlayerBackend UI->>Store: setTimeTimer(30) Store->>Rust: invoke("player_set_sleep_timer", {mode}) Rust->>Rust: Set SleepTimerMode::Time { end_time } Rust->>UI: Emit SleepTimerChanged event loop Every 1 second Thread->>Thread: update_remaining_seconds() Thread->>UI: Emit SleepTimerChanged (countdown) alt remaining_seconds == 0 Thread->>Backend: stop() Thread->>UI: Emit SleepTimerChanged (Off) end end ``` **Frontend Components:** - **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection. - **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue). - **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown. - **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active. **Key Design Decisions:** 1. **All logic in Rust**: Frontend only displays state and invokes commands 2. **Background timer thread**: Handles time-based countdown independently of track boundaries 3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary 4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display ## Auto-Play Episode Limit > ⚠️ **Autoplay is season-bounded.** `player/mod.rs:fetch_next_episode_for_item` > does not cross a season boundary, so autoplay stops at the end of a season even > though the "More Episodes" strip runs past it. Fixing it should reuse > `repository_get_series_episodes`, but it touches the playback state machine and > the Android JNI advance path (see the `AutoplayDecision` deadlock note in > [CLAUDE.md](../../CLAUDE.md)) — its own change, not a drive-by. **Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs` **TRACES**: UR-023 | DR-049 Limits how many episodes auto-play consecutively before requiring manual intervention. **Settings:** ```rust // In AutoplaySettings (runtime, in PlayerController) pub struct AutoplaySettings { pub enabled: bool, pub countdown_seconds: u32, pub max_episodes: u32, // 0 = unlimited } // In VideoSettings (persisted, settings page) pub struct VideoSettings { pub auto_play_next_episode: bool, pub auto_play_countdown_seconds: u32, pub auto_play_max_episodes: u32, // 0 = unlimited } ``` **Session-Based Counter:** The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes: - **Incremented**: In `on_playback_ended()` when auto-playing next episode - **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`) - **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue ```mermaid flowchart TB PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video
episode?"} CheckEpisode -->|"No"| AudioFlow["Audio queue logic"] CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"] FetchNext --> IncrementCount["increment_autoplay_count()"] IncrementCount --> CheckLimit{"max_episodes > 0
AND count >= max?"} CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup
auto_advance: true"] CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup
auto_advance: false"] ShowPopupManual --> UserClick["User clicks 'Play Now'"] UserClick --> PlayItem["play_item() -> resets counter"] ``` **Settings Sync:** `VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`. **Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`. **Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled. ## Player Page Navigation Guard **Location**: `src/routes/player/[id]/+page.svelte` When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback: ```typescript const alreadyPlayingMedia = get(storeCurrentMedia); if (alreadyPlayingMedia?.id === id && !startPosition) { // Track already playing - show UI without restarting playback // Fetch queue status for hasNext/hasPrevious return; } ``` **Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track. ## Playlist Management UI **TRACES**: UR-014 | JA-019 | JA-020 **Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte` The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support. **Components:** - **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`): - Modal for creating new playlists with a name input - Accepts optional `initialItemIds` to pre-populate with tracks - Keyboard support: Enter to create, Escape to close - Navigates to new playlist detail page on creation - **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`): - Modal listing all existing playlists to add tracks to - "New Playlist" button for inline creation flow - Shows playlist artwork via CachedImage - Loading state with skeleton placeholders - **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`): - Full playlist detail page with artwork, name, track count, total duration - Click-to-rename with inline editing - Play all / shuffle play buttons - Delete with confirmation dialog - Per-track removal buttons - Uses `TrackList` component for track display - Passes `{ type: "playlist", playlistId, playlistName }` context to player - **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`): - Grid view using `GenericMediaListPage` - Floating action button (FAB) to create new playlists - Search by playlist name **Frontend API Methods** (`src/lib/api/repository-client.ts`): - `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult` - `deletePlaylist(playlistId)` - `renamePlaylist(playlistId, name)` - `getPlaylistItems(playlistId)` -> `PlaylistEntry[]` - `addToPlaylist(playlistId, itemIds)` - `removeFromPlaylist(playlistId, entryIds)` - `movePlaylistItem(playlistId, itemId, newIndex)` **Offline Sync** (`src/lib/services/syncService.ts`): All playlist mutations are queued for offline sync: - `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename` - `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem` ## App Shell and Chrome **Location**: `src/lib/utils/layoutShell.ts` (pure rules), `src/lib/components/AppHeader.svelte`, `src/lib/components/account/AccountMenu.svelte`, `BottomUi.svelte` **TRACES**: UR-054 | DR-075, DR-076, DR-077 Account actions used to be reachable **only from `/library/*`** — the header that hosted them belonged to the library layout, the bottom nav offered Home / Search / Library, and the desktop username was inert text. From `/`, `/search` or `/downloads` there was no route to Settings or Sign out at all. The header is now shared and rendered from the root layout. ### Visibility rules All four rules are pure functions in `layoutShell.ts`, so the contract is unit-testable rather than a scattering of `$derived` booleans that drift per route and platform (which is what they were): | Function | Rule | |----------|------| | `showBottomNav` | Every authenticated route except `/player/*` and `/login` | | `showGlobalMiniPlayer` | Everything except `/player/*`, `/login`, `/settings`. **Not** gated on platform or `/library` — the root owns the mini player everywhere, so the library route must never render a second one | | `routeOwnsLayout` | `/library`, `/player/`, `/login` render their own full-height flex column; everything else renders into the root scroller | | `showGlobalHeader` | Authenticated, not a layout-owning route, not `/settings` (the user is already there) | ### The structural fix worth not undoing The "last row hidden behind the nav" bug is solved **structurally, not by measurement**: the bottom UI is an in-flow flex child *below* the scroller (`BottomUi.svelte`), so the scroller is physically bounded above it and cannot render behind it. There is no measurement and no reserved padding. If you restructure the shell, preserve the scroll containment — reintroducing padding math reintroduces the bug. ### AccountMenu One component for both breakpoints, anchored to the username/avatar (a real button with `aria-expanded`, not a bare three-dot icon). Fixed item order: identity block (user + server) → Downloads, Settings, Display → divider → Sign out, destructive and last. Dismissal is backdrop click, `Escape`, and focus return to the trigger. The identity block falls back to the bare host of the server URL when the server has no human-readable name, so it always shows *something* server-identifying. Settings' Display section and the library page-header toggle are two views onto the **same** persisted `viewMode` store (`jellytau-view-mode`) — no second state, no migration, and they stay in sync for free. ## Library Mosaic **Location**: `src/lib/components/library/libraryMosaic.ts` (pure), `MosaicGrid.svelte`, `MosaicTile.svelte` **TRACES**: UR-075, UR-067 | DR-174, DR-175 The library overview and the home "Your Libraries" strip are a **mosaic**, not a grid: rows share one height and each tile is as wide as its own artwork is, so a square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row at their own proportions instead of all three being cropped into whichever box a grid picked. `libraryMosaic.ts` is deliberately pure — it takes the libraries and returns the tiles to draw, so ordering and de-duplication are unit-testable rather than buried in markup. Tiles start at an *assumed* aspect (square, 16:9) and a measured image overrides it in `MosaicGrid`. Note what this file does **not** decide: which favourites category a library belongs to. That is Jellyfin vocabulary and arrives on the library itself as `favoritesScope`, from `SearchScope::for_collection_type` in Rust (see [01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). The frontend only decides what to *call* it and where to put it. ## Series and Episode Navigation **Location**: `src/lib/components/library/` — `SeasonSection.svelte`, `EpisodeFocusView.svelte`, `episodeStrip.ts` (pure) **TRACES**: UR-062 … UR-064 | DR-101 … DR-107 Opening a series lands the viewer where they actually are in it. **"Where is this viewer in this series" is resolved in Rust** (DR-101), not by the page: the series detail page asks the repository and anchors on the answer — the current season expanded, the current episode highlighted and scrolled into view, and a hero button labelled `Resume S2E4` / `Play S1E1`. A season is not a destination: `/library/` redirects to its series (DR-103). Video library routes collapse to one per library (DR-105). `episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted from the component because it had three distinct bugs that markup made untestable: the strip collapsing to just the current episode while real siblings existed, number-less episodes all matching as "current" (`undefined === undefined`), and the window dead-ending at a season boundary instead of running past it. It matches by id first and only falls back to season+episode number when both numbers are known on both sides. ## Downloaded Browse **Location**: `src/lib/services/downloadedCatalog.ts`, `src/lib/components/downloads/DownloadedBrowse.svelte` **TRACES**: UR-055, UR-056 | DR-081 … DR-085 `/downloads` is two views: **Downloaded** (the default) — the library filtered to what is on the device, reusing the same grids, cards and detail pages as online browsing — and **Transfers**, the in-flight progress rows demoted to a secondary tab. `downloadedCatalog` reads the **offline-only** browse path on the repository, never the hybrid merge. That is the point: an empty result means "nothing downloaded here", never "server unreachable", so the view is authoritative regardless of connectivity. It also owns disk usage — a per-item/container byte map plus the device total, aggregated by the backend from `downloads.file_size` (DR-085). ## Safe-area Insets **Location**: `src/app.css`, `WindowInsetsBridge.kt` **TRACES**: UR-066 | DR-112, IR-031 The Android WebView does not reliably report system-bar insets through `env(safe-area-inset-*)`. Native `WindowInsets` (`systemBars() | displayCutout()`) are therefore pushed in as CSS custom properties, and every edge takes the larger of the two sources: ```css --safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px)); ``` Two rules keep this from going wrong: **one owner per edge** (two components both padding the top edge double-pads it), and **no nested `h-screen`** — a full-height child inside a full-height parent that has already consumed the inset overflows by exactly the inset. Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it can safely be re-sent on resume. ## Native Video Store **Location**: `src/lib/stores/nativeVideo.ts` **TRACES**: UR-003, UR-004 | DR-188 Two separate concerns live here, deliberately: - `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**. Rust already decides *which backend this platform has* (`useHtml5Element` from `player_play_item`); this flag only *suppresses* that decision. It never turns native on where Rust says HTML5. An explicit stored choice wins in both directions, so someone who opted out is not re-enabled by a default flip — hence the `null` check rather than a bare `=== "true"`. - `nativeVideoActive` — whether a native surface is on screen *right now*. Setting it toggles `data-native-video` on ``, which is what the CSS in `app.css` keys off to clear the app's opaque backgrounds. It is deliberately **not** derived from the flag: the backgrounds must come back the moment the player unmounts. See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android) for what is behind the WebView. ## Logging **Location**: `src/lib/utils/logger.ts` **TRACES**: DR-204 The frontend's equivalent of the Rust `log` crate: four levels (`debug < info < warn < error`), a compile-environment default (dev → `debug`, production → `warn`), and a runtime override that is the moral equivalent of `RUST_LOG`. Scoped loggers carry the subsystem in the message, so a filtered console stays usable while a player, a download worker and a store are all talking. Production deliberately keeps **warn and error**: this is a client talking to a server that may or may not be there, and a silent failure is worse to support than a noisy console. Only the chatter is suppressed. `no-console` is an ESLint **error**, with the sink module itself the only exception, so a raw `console.*` cannot re-appear.