# Rust Backend Architecture **Location**: `src-tauri/src/` ## Media Session State Machine **Location**: `src-tauri/src/player/session.rs` The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types. **Architecture Note:** The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns. ```mermaid stateDiagram-v2 [*] --> Idle Idle --> AudioActive : play_queue(audio) Idle --> MovieActive : play_item(movie) Idle --> TvShowActive : play_item(episode) state "Audio Session" as AudioSession { [*] --> AudioActive AudioActive --> AudioInactive : playback_ended AudioInactive --> AudioActive : resume/play AudioActive --> AudioActive : next/previous } state "Movie Session" as MovieSession { [*] --> MovieActive MovieActive --> MovieInactive : playback_ended MovieInactive --> MovieActive : resume } state "TV Show Session" as TvShowSession { [*] --> TvShowActive TvShowActive --> TvShowInactive : playback_ended TvShowInactive --> TvShowActive : next_episode/resume } AudioSession --> Idle : dismiss/clear_queue AudioSession --> MovieSession : play_item(movie) AudioSession --> TvShowSession : play_item(episode) MovieSession --> Idle : dismiss/playback_complete MovieSession --> AudioSession : play_queue(audio) TvShowSession --> Idle : dismiss/series_complete TvShowSession --> AudioSession : play_queue(audio) note right of Idle No active media session Queue may exist but not playing No miniplayer/video player shown end note note right of AudioSession SHOW: Miniplayer (always visible) - Active: Play/pause/skip controls enabled - Inactive: Play button to resume queue Persists until explicit dismiss end note note right of MovieSession SHOW: Full video player - Active: Video playing/paused - Inactive: Resume dialog Auto-dismiss when playback ends end note note right of TvShowSession SHOW: Full video player + Next Episode UI - Active: Video playing/paused - Inactive: Next episode prompt Auto-dismiss when series ends end note ``` **Session State Enum:** ```rust #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum MediaSessionType { /// No active session - browsing library Idle, /// Audio playback session (music, audiobooks, podcasts) /// Persists until explicitly dismissed Audio { /// Last/current track being played last_item: Option, /// True = playing/paused, False = stopped/ended is_active: bool, }, /// Movie playback (single video, auto-dismiss on end) Movie { item: MediaItem, is_active: bool, // true = playing/paused, false = ended }, /// TV show playback (supports next episode auto-advance) TvShow { item: MediaItem, series_id: String, is_active: bool, // true = playing/paused, false = ended }, } ``` **State Transitions & Rules:** | From State | Event | To State | UI Behavior | Notes | |------------|-------|----------|-------------|-------| | Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session | | Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session | | Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session | | Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved | | Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue | | Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session | | Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session | | Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss | | Movie (active) | `dismiss` | Idle | Hide video player | User dismiss | | TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice | | TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session | | TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes | **Key Design Decisions:** 1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume 2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused) 3. **Single Active Session**: Playing new content type replaces current session 4. **Explicit Dismiss for Audio**: User must click close button to clear audio session 5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking **Edge Cases Handled:** - Album finishes: Session goes inactive, miniplayer shows last track with play disabled - User wants to dismiss: Close button clears session -> Idle - Switch content types: New session replaces old (audio -> movie) - Paused for extended time: Session persists indefinitely - Playback errors: Session stays inactive, allows retry - Queue operations while idle: Queue exists but no session created until play ## Player State Machine (Low-Level Playback) **Location**: `src-tauri/src/player/state.rs` The player uses a deterministic state machine with 6 states (operates within a media session): ```mermaid stateDiagram-v2 [*] --> Idle Idle --> Loading : Load Loading --> Playing : MediaLoaded Playing --> Paused : Pause Paused --> Playing : Play Paused --> Seeking : Seek Seeking --> Playing : PositionUpdate Playing --> Idle : Stop Paused --> Idle : Stop Idle --> Error : Error Loading --> Error : Error Playing --> Error : Error Paused --> Error : Error Seeking --> Error : Error state Playing { [*] : position, duration } state Paused { [*] : position, duration } state Seeking { [*] : target } state Error { [*] : error message } ``` **State Enum:** ```rust pub enum PlayerState { Idle, Loading { media: MediaItem }, Playing { media: MediaItem, position: f64, duration: f64 }, Paused { media: MediaItem, position: f64, duration: f64 }, Seeking { media: MediaItem, target: f64 }, Error { media: Option, error: String }, } ``` **Event Enum:** ```rust pub enum PlayerEvent { Load(MediaItem), Play, Pause, Stop, Seek(f64), Next, Previous, MediaLoaded(f64), // duration PositionUpdate(f64), // position PlaybackEnded, Error(String), } ``` ## Playback Mode State Machine **Location**: `src-tauri/src/playback_mode/mod.rs` The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.): ```mermaid stateDiagram-v2 [*] --> Idle Idle --> Local : play_queue() Idle --> Remote : transfer_to_remote(session_id) Local --> Remote : transfer_to_remote(session_id) Local --> Idle : stop() Remote --> Local : transfer_to_local() Remote --> Idle : session_disconnected() Remote --> Idle : stop() state Local { [*] : Playing on device [*] : ExoPlayer active [*] : Volume buttons -> device } state Remote { [*] : Controlling session [*] : session_id [*] : Volume buttons -> remote [*] : Android: VolumeProvider active } state Idle { [*] : No active playback } ``` **State Enum:** ```rust pub enum PlaybackMode { Local, // Playing on local device Remote { session_id: String }, // Controlling remote Jellyfin session Idle, // No active playback } ``` **State Transitions:** | From | Event | To | Side Effects | |------|-------|-----|----| | Idle | `play_queue()` | Local | Start local playback | | Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session | | Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android) | | Local | `stop()` | Idle | Stop local playback | | Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume | | Remote | `stop()` | Idle | Stop remote playback, disable remote volume | | Remote | `session_disconnected()` | Idle | Session lost, disable remote volume | **Integration with Player State Machine:** - When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.) - When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback) - When `PlaybackMode = Idle`: Player state is Idle **Android Volume Control Integration:** When transitioning to `Remote` mode on Android: 1. Call `enable_remote_volume(initial_volume)` 2. VolumeProviderCompat intercepts hardware volume buttons 3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI) 4. Volume commands routed to remote session via Jellyfin API When transitioning away from `Remote` mode: 1. Call `disable_remote_volume()` 2. Volume buttons return to controlling device volume 3. PlaybackStateCompat set to STATE_NONE 4. VolumeProviderCompat is cleared ## Media Item & Source **Location**: `src-tauri/src/player/media.rs` ```rust pub struct MediaItem { pub id: String, pub title: String, pub artist: Option, pub album: Option, pub duration: Option, pub artwork_url: Option, pub media_type: MediaType, pub source: MediaSource, } pub enum MediaType { Audio, Video, } pub enum MediaSource { Remote { stream_url: String, jellyfin_item_id: String, }, Local { file_path: PathBuf, jellyfin_item_id: Option, }, DirectUrl { url: String, }, } ``` The `MediaSource` enum enables: - **Remote**: Streaming from Jellyfin server - **Local**: Downloaded/cached files (future offline support) - **DirectUrl**: Direct URLs (channel plugins, external sources) ## Queue Manager **Location**: `src-tauri/src/player/queue.rs` ```rust pub struct QueueManager { items: Vec, current_index: Option, shuffle: bool, repeat: RepeatMode, shuffle_order: Vec, // Fisher-Yates permutation history: Vec, // For back navigation in shuffle } pub enum RepeatMode { Off, All, One, } ``` **Queue Navigation Logic:** ```mermaid flowchart TB QM[QueueManager] QM --> Shuffle QM --> Repeat QM --> History subgraph Shuffle["Shuffle Mode"] ShuffleOff["OFF
next() returns index + 1"] ShuffleOn["ON
next() follows shuffle_order[]"] end subgraph Repeat["Repeat Mode"] RepeatOff["OFF
next() at end: -> None"] RepeatAll["ALL
next() at end: -> wrap to index 0"] RepeatOne["ONE
next() returns same item"] end subgraph History["History"] HistoryDesc["Used for previous()
in shuffle mode"] end ``` ## Favorites System **Location**: - Service: `src/lib/services/favorites.ts` - Component: `src/lib/components/FavoriteButton.svelte` - Backend: `src-tauri/src/commands/storage.rs` The favorites system implements optimistic updates with server synchronization: ```mermaid flowchart TB UI[FavoriteButton] -->|Click| Service[toggleFavorite] Service -->|1. Optimistic| LocalDB[(SQLite user_data)] Service -->|2. Sync| JellyfinAPI[Jellyfin API] Service -->|3. Mark Synced| LocalDB JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"] JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"] LocalDB -->|is_favorite
pending_sync| UserData[user_data table] ``` **Flow**: 1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages) 2. `toggleFavorite()` service function handles the logic: - Updates local SQLite database immediately (optimistic update) - Attempts to sync with Jellyfin server - Marks as synced if successful, otherwise leaves `pending_sync = 1` 3. UI reflects the change immediately without waiting for server response **Components**: - **FavoriteButton.svelte**: Reusable heart button component - Configurable size (sm/md/lg) - Red when favorited, gray when not - Loading state during toggle - Bindable `isFavorite` prop for two-way binding - **Integration Points**: - MiniPlayer: Shows favorite button for audio tracks (hidden on small screens) - Full AudioPlayer: Shows favorite button (planned) - Album/Artist detail pages: Shows favorite button (planned) **Database Schema**: - `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1) - `user_data.pending_sync`: Indicates if local changes need syncing **Tauri Commands**: - `storage_toggle_favorite`: Updates favorite status in local database - `storage_mark_synced`: Clears pending_sync flag after successful sync **API Methods**: - `LibraryApi.markFavorite(itemId)`: POST to Jellyfin - `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin ## Player Backend Trait **Location**: `src-tauri/src/player/backend.rs` ```rust pub trait PlayerBackend: Send + Sync { fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>; fn play(&mut self) -> Result<(), PlayerError>; fn pause(&mut self) -> Result<(), PlayerError>; fn stop(&mut self) -> Result<(), PlayerError>; fn seek(&mut self, position: f64) -> Result<(), PlayerError>; fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>; fn position(&self) -> f64; fn duration(&self) -> Option; fn state(&self) -> PlayerState; fn is_loaded(&self) -> bool; fn volume(&self) -> f32; } ``` **Implementations:** - `NullBackend` - Mock backend for testing - `MpvBackend` - Linux playback via libmpv (see [05-platform-backends.md](05-platform-backends.md)) - `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md)) ## Player Controller **Location**: `src-tauri/src/player/mod.rs` The `PlayerController` orchestrates playback: ```rust pub struct PlayerController { backend: Arc>>, queue: Arc>, muted: bool, sleep_timer: Arc>, autoplay_settings: Arc>, autoplay_episode_count: Arc>, // Session-based counter repository: Arc>>>, event_emitter: Arc>>>, // ... other fields } ``` **Key Methods:** - `play_item(item)`: Load and play single item (resets autoplay counter) - `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter) - `next()` / `previous()`: Queue navigation (resets autoplay counter) - `toggle_shuffle()` / `cycle_repeat()`: Mode changes - `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control - `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue) ## Playlist System **Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/` **TRACES**: UR-014 | JA-019 | JA-020 The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern. **Types:** ```rust /// A media item within a playlist, with its distinct playlist entry ID #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistEntry { /// Jellyfin's PlaylistItemId (distinct from the media item ID) pub playlist_item_id: String, #[serde(flatten)] pub item: MediaItem, } /// Result of creating a new playlist #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlaylistCreatedResult { pub id: String, } ``` **Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times. **MediaRepository Trait Methods:** ```rust async fn create_playlist(&self, name: &str, item_ids: Option>) -> Result; async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>; async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>; async fn get_playlist_items(&self, playlist_id: &str) -> Result, RepoError>; async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec) -> Result<(), RepoError>; async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec) -> Result<(), RepoError>; async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>; ``` **Cache Strategy:** - **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository - **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback) - Background cache update after server fetch via `save_playlist_items_to_cache()` **Playlist Tauri Commands:** | Command | Parameters | Returns | |---------|------------|---------| | `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` | | `playlist_delete` | `handle, playlist_id` | `()` | | `playlist_rename` | `handle, playlist_id, name` | `()` | | `playlist_get_items` | `handle, playlist_id` | `Vec` | | `playlist_add_items` | `handle, playlist_id, item_ids` | `()` | | `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` | | `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` | ## Tauri Commands (Player) **Location**: `src-tauri/src/commands/player.rs` | Command | Parameters | Returns | |---------|------------|---------| | `player_play_item` | `PlayItemRequest` | `PlayerStatus` | | `player_play_queue` | `items, start_index, shuffle` | `PlayerStatus` | | `player_play` | - | `PlayerStatus` | | `player_pause` | - | `PlayerStatus` | | `player_toggle` | - | `PlayerStatus` | | `player_stop` | - | `PlayerStatus` | | `player_next` | - | `PlayerStatus` | | `player_previous` | - | `PlayerStatus` | | `player_seek` | `position: f64` | `PlayerStatus` | | `player_set_volume` | `volume: f32` | `PlayerStatus` | | `player_toggle_shuffle` | - | `QueueStatus` | | `player_cycle_repeat` | - | `QueueStatus` | | `player_get_status` | - | `PlayerStatus` | | `player_get_queue` | - | `QueueStatus` | | `player_get_session` | - | `MediaSessionType` | | `player_dismiss_session` | - | `()` | | `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` | | `player_cancel_sleep_timer` | - | `()` | | `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` | | `player_get_video_settings` | - | `VideoSettings` | | `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` | | `player_get_autoplay_settings` | - | `AutoplaySettings` | | `player_on_playback_ended` | - | `()` |