19 KiB
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.
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:
#[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<MediaItem>,
/// 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:
- Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
- Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
- Single Active Session: Playing new content type replaces current session
- Explicit Dismiss for Audio: User must click close button to clear audio session
- 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):
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:
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<MediaItem>, error: String },
}
Event Enum:
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.):
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:
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:
- Call
enable_remote_volume(initial_volume) - VolumeProviderCompat intercepts hardware volume buttons
- PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
- Volume commands routed to remote session via Jellyfin API
When transitioning away from Remote mode:
- Call
disable_remote_volume() - Volume buttons return to controlling device volume
- PlaybackStateCompat set to STATE_NONE
- VolumeProviderCompat is cleared
Media Item & Source
Location: src-tauri/src/player/media.rs
pub struct MediaItem {
pub id: String,
pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
pub duration: Option<f64>,
pub artwork_url: Option<String>,
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<String>,
},
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
pub struct QueueManager {
items: Vec<MediaItem>,
current_index: Option<usize>,
shuffle: bool,
repeat: RepeatMode,
shuffle_order: Vec<usize>, // Fisher-Yates permutation
history: Vec<usize>, // For back navigation in shuffle
}
pub enum RepeatMode {
Off,
All,
One,
}
Queue Navigation Logic:
flowchart TB
QM[QueueManager]
QM --> Shuffle
QM --> Repeat
QM --> History
subgraph Shuffle["Shuffle Mode"]
ShuffleOff["OFF<br/>next() returns index + 1"]
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
end
subgraph Repeat["Repeat Mode"]
RepeatOff["OFF<br/>next() at end: -> None"]
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
RepeatOne["ONE<br/>next() returns same item"]
end
subgraph History["History"]
HistoryDesc["Used for previous()<br/>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:
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<br/>pending_sync| UserData[user_data table]
Flow:
- User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
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
- 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
isFavoriteprop 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 databasestorage_mark_synced: Clears pending_sync flag after successful sync
API Methods:
LibraryApi.markFavorite(itemId): POST to JellyfinLibraryApi.unmarkFavorite(itemId): DELETE from Jellyfin
Player Backend Trait
Location: src-tauri/src/player/backend.rs
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<f64>;
fn state(&self) -> PlayerState;
fn is_loaded(&self) -> bool;
fn volume(&self) -> f32;
}
Implementations:
NullBackend- Mock backend for testingMpvBackend- Linux playback via libmpv (see 05-platform-backends.md)ExoPlayerBackend- Android playback via ExoPlayer/Media3 (see 05-platform-backends.md)
Player Controller
Location: src-tauri/src/player/mod.rs
The PlayerController orchestrates playback:
pub struct PlayerController {
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
muted: bool,
sleep_timer: Arc<Mutex<SleepTimerState>>,
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
// ... 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 changesset_sleep_timer(mode)/cancel_sleep_timer(): Sleep timer controlon_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:
/// 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:
async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
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<Vec<PlaylistEntry>, RepoError>;
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> 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<PlaylistEntry> |
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 |
- | () |