# 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:** | Content Type | View Mode | Toggle Visible | Component Used | |--------------|-----------|----------------|----------------| | Tracks | List (forced) | No | `TrackList` | | Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | | Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | | Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` | | Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` | | Album Detail Tracks | List (forced) | No | `TrackList` | **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 forceGrid Prop:** The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork. ## 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 +new() } 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~ -connectivity: Arc~ConnectivityMonitor~ +new() -parallel_query() -has_meaningful_content() } 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 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 **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`