Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s

This commit is contained in:
Duncan Tourolle 2026-03-01 19:47:46 +01:00
parent 3a9c126dfe
commit 09780103a7
45 changed files with 5663 additions and 3332 deletions

View File

@ -27,7 +27,7 @@ A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
| UR-011 | Download media on demand | Medium | Done | | UR-011 | Download media on demand | Medium | Done |
| UR-012 | Login info shall be stored securely and persistently | High | Done | | UR-012 | Login info shall be stored securely and persistently | High | Done |
| UR-013 | View and manage downloaded media | Medium | Done | | UR-013 | View and manage downloaded media | Medium | Done |
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Planned | | 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-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned | | UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done | | UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
@ -113,8 +113,8 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-016 | Get recently added items | Items | UR-024 | Done | | JA-016 | Get recently added items | Items | UR-024 | Done |
| JA-017 | Mark item as favorite | UserData | UR-017 | Done | | JA-017 | Mark item as favorite | UserData | UR-017 | Done |
| JA-018 | Remove item from favorites | UserData | UR-017 | Done | | JA-018 | Remove item from favorites | UserData | UR-017 | Done |
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned | | JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned | | JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
| JA-021 | Get active sessions list | Sessions | UR-010 | 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-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-023 | Send seek command to remote session | Sessions | UR-010 | Done |
@ -146,12 +146,12 @@ Internal architecture, components, and application logic.
| DR-011 | Search bar with cross-library search | UI | UR-008 | 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-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-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 | Planned | | 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-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-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-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-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Planned | | DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | 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-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-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
@ -205,7 +205,7 @@ Internal architecture, components, and application logic.
| UR-011 | IR-013 | DR-003, DR-015, DR-018 | | UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - | | UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 | | UR-013 | IR-013 | DR-017 |
| UR-014 | - | DR-014, DR-019 | | UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 | | UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - | | UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 | | UR-017 | - | DR-014, DR-021 |
@ -288,6 +288,16 @@ Internal architecture, components, and application logic.
| UT-046 | Smart cache album affinity tracking | DR-015 | Done | | UT-046 | Smart cache album affinity tracking | DR-015 | Done |
| UT-047 | Smart cache queue precache config | DR-015 | Done | | UT-047 | Smart cache queue precache config | DR-015 | Done |
| UT-048 | Smart cache storage limit check | 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 |
### Integration Tests ### Integration Tests

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,571 @@
# 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<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:**
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<MediaItem>, 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<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`
```rust
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:**
```mermaid
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:
```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<br/>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<f64>;
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<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 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<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` | - | `()` |

View File

@ -0,0 +1,644 @@
# Svelte Frontend Architecture
## Store Structure
**Location**: `src/lib/stores/`
```mermaid
flowchart TB
subgraph Stores
subgraph auth["auth.ts"]
AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
end
subgraph playerStore["player.ts"]
PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
end
subgraph queueStore["queue.ts"]
QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
end
subgraph libraryStore["library.ts"]
LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
end
subgraph Derived["Derived Stores"]
DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>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<br/>(Landing page with category cards)"]
Tracks["Tracks<br/>(List view only)"]
Artists["Artists<br/>(Grid view)"]
Albums["Albums<br/>(Grid view)"]
Playlists["Playlists<br/>(Grid view)"]
Genres["Genres<br/>(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
<TrackList
tracks={filteredTracks}
loading={loading}
showArtist={true}
showAlbum={true}
/>
```
**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<br/>(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 {
<<trait>>
+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<Library>
repository_get_items(handle: String, ...) -> SearchResult
// Cleanup when done
repository_destroy(handle: String)
```
- Enables multiple concurrent repository instances
- Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
- 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<T>("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<Mutex<PlayerController>>,
jellyfin_client: Arc<JellyfinClient>,
}
```
**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<usize, DatabaseError>;
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
where F: Fn(&Row) -> Result<T> + Send + 'static;
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
}
pub struct RusqliteService {
connection: Arc<Mutex<Connection>>,
}
impl DatabaseService for RusqliteService {
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
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<br/>episode?"}
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
FetchNext --> IncrementCount["increment_autoplay_count()"]
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>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`

View File

@ -0,0 +1,154 @@
# Data Flow
## Repository Query Flow (Cache-First)
```mermaid
sequenceDiagram
participant UI as Svelte Component
participant Client as RepositoryClient (TS)
participant Rust as Tauri Command
participant Hybrid as HybridRepository
participant Cache as OfflineRepository (SQLite)
participant Server as OnlineRepository (HTTP)
UI->>Client: getItems(parentId)
Client->>Rust: invoke("repository_get_items", {handle, parentId})
Rust->>Hybrid: get_items()
par Parallel Racing
Hybrid->>Cache: get_items() with 100ms timeout
Hybrid->>Server: get_items() (no timeout)
end
alt Cache returns with content
Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result
else Cache timeout or empty
Server-->>Hybrid: Fresh result
Hybrid-->>Rust: Return server result
end
Rust-->>Client: SearchResult
Client-->>UI: items[]
Note over UI: Reactive update
```
**Key Points:**
- Cache queries have 100ms timeout for responsiveness
- Server queries always run for fresh data
- Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale
- Background cache updates (planned)
## Playback Initiation Flow
```mermaid
sequenceDiagram
participant User
participant AudioPlayer
participant Tauri as Tauri IPC
participant Command as player_play_item()
participant Controller as PlayerController
participant Backend as PlayerBackend
participant Store as Frontend Store
User->>AudioPlayer: clicks play
AudioPlayer->>Tauri: invoke("player_play_item", {item})
Tauri->>Command: player_play_item()
Command->>Command: Convert PlayItemRequest -> MediaItem
Command->>Controller: play_item(item)
Controller->>Backend: load(item)
Note over Backend: State -> Loading
Controller->>Backend: play()
Note over Backend: State -> Playing
Controller-->>Command: Ok(())
Command-->>Tauri: PlayerStatus {state, position, duration, volume}
Tauri-->>AudioPlayer: status
AudioPlayer->>Store: player.setPlaying(media, position, duration)
Note over Store: UI updates reactively
```
## Playback Mode Transfer Flow
```mermaid
sequenceDiagram
participant UI as Cast Button
participant Store as playbackMode store
participant Rust as Tauri Command
participant Manager as PlaybackModeManager
participant Player as PlayerController
participant Jellyfin as Jellyfin API
UI->>Store: transferToRemote(sessionId)
Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
Rust->>Manager: transfer_to_remote()
Manager->>Player: Get current queue
Player-->>Manager: Vec<MediaItem>
Manager->>Manager: Extract Jellyfin IDs
Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
Jellyfin-->>Manager: 200 OK
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
Jellyfin-->>Manager: 200 OK
Manager->>Player: stop()
Manager->>Manager: mode = Remote {sessionId}
Manager-->>Rust: Ok(())
Rust-->>Store: PlaybackMode
Store->>UI: Update cast icon
```
## Queue Navigation Flow
```mermaid
flowchart TB
User["User clicks Next"] --> Invoke["invoke('player_next')"]
Invoke --> ControllerNext["controller.next()"]
ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
QueueNext --> None["None<br/>(at end)"]
QueueNext --> Some["Some(next)"]
QueueNext --> Same["Same<br/>(repeat one)"]
Some --> PlayItem["play_item(next)<br/>Returns new status"]
```
## Volume Control Flow
```mermaid
sequenceDiagram
participant User
participant Slider as Volume Slider
participant Handler as handleVolumeChange()
participant Tauri as Tauri IPC
participant Command as player_set_volume
participant Controller as PlayerController
participant Backend as MpvBackend/NullBackend
participant Events as playerEvents.ts
participant Store as Player Store
participant UI
User->>Slider: adjusts (0-100)
Slider->>Handler: oninput event
Handler->>Handler: Convert 0-100 -> 0.0-1.0
Handler->>Tauri: invoke("player_set_volume", {volume})
Tauri->>Command: player_set_volume
Command->>Controller: set_volume(volume)
Controller->>Backend: set_volume(volume)
Backend->>Backend: Clamp to 0.0-1.0
Note over Backend: MpvBackend: Send to MPV loop
Backend-->>Tauri: emit "player-event"
Tauri-->>Events: VolumeChanged event
Events->>Store: player.setVolume(volume)
Store-->>UI: Reactive update
Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
```
**Key Implementation Details:**
- Volume is stored in the backend (NullBackend/MpvBackend)
- `PlayerController.volume()` delegates to backend
- `get_player_status()` returns `controller.volume()` (not hardcoded)
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100

View File

@ -0,0 +1,132 @@
# Type Synchronization & Thread Safety
## PlayerState (Rust <-> TypeScript)
**Rust:**
```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<MediaItem>, error: String },
}
```
**TypeScript:**
```typescript
type PlayerState =
| { kind: "idle" }
| { kind: "loading"; media: MediaItem }
| { kind: "playing"; media: MediaItem; position: number; duration: number }
| { kind: "paused"; media: MediaItem; position: number; duration: number }
| { kind: "seeking"; media: MediaItem; target: number }
| { kind: "error"; media: MediaItem | null; error: string };
```
## MediaItem Serialization
```rust
// Rust (serde serialization)
#[derive(Serialize, Deserialize)]
pub struct MediaItem {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
// ...
}
```
```typescript
// TypeScript
interface MediaItem {
id: string;
title: string;
artist?: string;
// ...
}
```
## Tauri v2 IPC Parameter Naming Convention
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`
```typescript
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
await invoke("player_play_tracks", {
repositoryHandle: "handle-123", // Rust: repository_handle
request: { trackIds: ["id1"], startIndex: 0 }
});
await invoke("remote_send_command", {
sessionId: "session-123", // Rust: session_id
command: "PlayPause"
});
await invoke("pin_item", {
itemId: "item-123" // Rust: item_id
});
// WRONG - snake_case causes "invalid args request" error on Android
await invoke("player_play_tracks", {
repository_handle: "handle-123", // Will fail!
});
```
**Parameter Name Mapping (Rust -> Frontend)**:
| Rust Parameter | Frontend Parameter | Used By |
|----------------|-------------------|----|
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
| `item_ids` | `itemIds` | `remote_play_on_session` |
| `start_index` | `startIndex` | `remote_play_on_session` |
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
## Rust Backend Thread Safety
```rust
// Shared state wrapped in Arc<Mutex<>>
pub struct PlayerController {
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
// ...
}
// Tauri state wrapper
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
// Command handler pattern
#[tauri::command]
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
let mut controller = state.0.lock().unwrap(); // Acquire lock
controller.play()?; // Operate
Ok(get_player_status(&controller)) // Lock released
}
```
## Frontend Stores
Svelte stores are inherently reactive and thread-safe for UI updates:
```typescript
const { subscribe, update } = writable<PlayerStore>(initialState);
// Atomic updates
function setPlaying(media: MediaItem, position: number, duration: number) {
update(state => ({
...state,
state: { kind: "playing", media, position, duration }
}));
}
```

View File

@ -0,0 +1,485 @@
# Platform-Specific Player Backends
## Player Events System
**Location**: `src-tauri/src/player/events.rs`
The player uses a push-based event system to notify the frontend of state changes:
```rust
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
PositionUpdate { position: f64, duration: f64 },
/// Player state changed
StateChanged { state: String, media_id: Option<String> },
/// Media has finished loading and is ready to play
MediaLoaded { duration: f64 },
/// Playback has ended naturally
PlaybackEnded,
/// Buffering state changed
Buffering { percent: u8 },
/// An error occurred during playback
Error { message: String, recoverable: bool },
/// Volume changed
VolumeChanged { volume: f32, muted: bool },
/// Sleep timer state changed
SleepTimerChanged {
mode: SleepTimerMode,
remaining_seconds: u32,
},
/// Show next episode popup with countdown
ShowNextEpisodePopup {
current_episode: MediaItem,
next_episode: MediaItem,
countdown_seconds: u32,
auto_advance: bool,
},
/// Countdown tick (emitted every second during autoplay countdown)
CountdownTick { remaining_seconds: u32 },
/// Queue changed (items added, removed, reordered, or playback mode changed)
QueueChanged {
items: Vec<MediaItem>,
current_index: Option<usize>,
shuffle: bool,
repeat: RepeatMode,
has_next: bool,
has_previous: bool,
},
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
SessionChanged { session: MediaSessionType },
}
```
Events are emitted via Tauri's event system:
```mermaid
flowchart LR
subgraph Backend["Player Backend"]
MPV["MPV/ExoPlayer"]
end
subgraph EventSystem["Event System"]
Emitter["TauriEventEmitter<br/>emit()"]
Bus["Tauri Event Bus<br/>'player-event'"]
end
subgraph Frontend["Frontend"]
Listener["playerEvents.ts<br/>Frontend Listener"]
Store["Player Store Update<br/>(position, state, etc)"]
end
MPV --> Emitter --> Bus --> Listener --> Store
```
**Frontend Listener** (`src/lib/services/playerEvents.ts`):
- Listens for `player-event` Tauri events
- Updates player/queue stores based on event type
- Auto-advances to next track on `PlaybackEnded`
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/`
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
```mermaid
flowchart TB
subgraph MainThread["Main Thread"]
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
end
subgraph EventLoopThread["MPV Event Loop Thread"]
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
TauriEmitter["TauriEventEmitter"]
end
MpvBackend -->|"MpvCommand"| EventLoop
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
EventLoop -->|"Events"| TauriEmitter
TauriEmitter --> FrontendStore["Frontend Store"]
```
**Key Components:**
```rust
// Command enum sent to event loop thread
pub enum MpvCommand {
Load { url: String, media: MediaItem },
Play,
Pause,
Stop,
Seek(f64),
SetVolume(f32),
Quit,
}
// Shared state between main thread and event loop
pub struct MpvSharedState {
pub state: PlayerState,
pub position: f64,
pub duration: Option<f64>,
pub volume: f32,
pub is_loaded: bool,
pub current_media: Option<MediaItem>,
}
```
**Event Loop** (`event_loop.rs`):
- Initializes MPV with audio-only config (`vo=null`, `video=false`)
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
- Emits position updates every 250ms during playback
- Processes commands from channel (non-blocking)
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`
## ExoPlayerBackend (Android)
**Location**: `src-tauri/src/player/android/` and Kotlin sources
The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.
```mermaid
flowchart TB
subgraph RustNative["Rust (Native)"]
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
TauriEmitter2["TauriEventEmitter"]
end
subgraph KotlinJVM["Kotlin (JVM)"]
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
end
ExoBackend -->|"JNI Calls"| JellyTauPlayer
JellyTauPlayer -->|"Callbacks"| NativeFuncs
NativeFuncs --> TauriEmitter2
TauriEmitter2 --> FrontendStore2["Frontend Store"]
```
**Kotlin Player** (`JellyTauPlayer.kt`):
```kotlin
class JellyTauPlayer(context: Context) {
private val exoPlayer: ExoPlayer
private var positionUpdateJob: Job?
// Methods callable from Rust via JNI
fun load(url: String, mediaId: String)
fun play()
fun pause()
fun stop()
fun seek(positionSeconds: Double)
fun setVolume(volume: Float)
// Native callbacks to Rust
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
private external fun nativeOnStateChanged(state: String, mediaId: String?)
private external fun nativeOnMediaLoaded(duration: Double)
private external fun nativeOnPlaybackEnded()
}
```
**JNI Callbacks** (Rust):
```rust
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
) {
// Update shared state
// Emit PlayerStatusEvent::PositionUpdate
}
```
## Android MediaSession & Remote Volume Control
**Location**: `JellyTauPlaybackService.kt`
JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:
```mermaid
flowchart TB
subgraph Service["JellyTauPlaybackService"]
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
MediaSessionCompat --> VolumeProvider
end
subgraph Hardware["System"]
VolumeButtons["Hardware Volume Buttons"]
Lockscreen["Lockscreen Controls"]
Notification["Media Notification"]
end
subgraph Rust["Rust Backend"]
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
end
VolumeButtons --> VolumeProvider
Lockscreen --> MediaSession
Notification --> MediaSession
VolumeProvider --> JNI
JNI --> PlaybackMode
PlaybackMode --> JellyfinAPI
```
**Architecture Rationale:**
JellyTau maintains both MediaSession types because they serve different purposes:
1. **Media3 MediaSession**: Handles lockscreen/notification playback controls (play/pause/next/previous)
2. **MediaSessionCompat**: Intercepts hardware volume button presses for remote playback control
When in remote playback mode (controlling a Jellyfin session on another device):
- Volume buttons are routed through `VolumeProviderCompat`
- Volume changes are sent to the remote session via Jellyfin API
- System volume UI shows the remote session's volume level
**Remote Volume Flow:**
```mermaid
sequenceDiagram
participant User
participant VolumeButton as Hardware Volume Button
participant VolumeProvider as VolumeProviderCompat
participant JNI as nativeOnRemoteVolumeChange
participant PlaybackMode as PlaybackModeManager
participant Jellyfin as Jellyfin Server
participant RemoteSession as Remote Session (TV/Browser)
User->>VolumeButton: Press Volume Up
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
Jellyfin->>RemoteSession: Set volume to new level
RemoteSession-->>User: Volume changes on TV/Browser
```
**Key Implementation Details:**
**Enabling Remote Volume** (`enableRemoteVolume()`):
```kotlin
fun enableRemoteVolume(initialVolume: Int) {
volumeProvider = object : VolumeProviderCompat(
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
100, // Max volume
initialVolume
) {
override fun onSetVolumeTo(volume: Int) {
remoteVolumeLevel = volume.coerceIn(0, 100)
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
}
override fun onAdjustVolume(direction: Int) {
when (direction) {
AudioManager.ADJUST_RAISE -> {
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
currentVolume = remoteVolumeLevel
}
AudioManager.ADJUST_LOWER -> {
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
currentVolume = remoteVolumeLevel
}
}
}
}
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
}
```
**Disabling Remote Volume** (`disableRemoteVolume()`):
```kotlin
fun disableRemoteVolume() {
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
volumeProvider = null
}
```
**Rust Integration** (`src-tauri/src/player/android/mod.rs`):
```rust
/// Enable remote volume control on Android
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
start_playback_service()?;
let service_instance = get_playback_service_instance()?;
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
&[JValue::Int(initial_volume)])?;
Ok(())
}
```
**Dependencies** (`src-tauri/android/build.gradle.kts`):
```kotlin
dependencies {
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
}
```
**Integration with Playback Mode:**
Remote volume is automatically enabled/disabled during playback mode transfers:
```rust
// In PlaybackModeManager::transfer_to_remote()
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("Failed to enable remote volume: {}", e);
}
}
// In PlaybackModeManager::transfer_to_local()
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("Failed to disable remote volume: {}", e);
}
}
```
## Android Album Art Caching
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
```mermaid
flowchart TB
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
end
subgraph Cache["AlbumArtCache.kt"]
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
end
subgraph Service["JellyTauPlaybackService.kt"]
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
end
LoadMedia --> AsyncDownload
AsyncDownload --> MemoryCache
MemoryCache --> Download
Download --> ErrorHandle
AsyncDownload --> UpdateMeta
UpdateMeta --> Notification
```
**AlbumArtCache Singleton:**
```kotlin
class AlbumArtCache(context: Context) {
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024 // Size in KB
}
}
suspend fun getArtwork(url: String): Bitmap? {
memoryCache.get(url)?.let { return it }
return downloadAndCache(url)
}
private suspend fun downloadAndCache(url: String): Bitmap? =
withContext(Dispatchers.IO) {
// HTTP download with 5s timeout
// Scale to 512x512 max
// Auto-evict LRU if needed
}
}
```
**Integration Flow:**
1. **Track Load** (`loadWithMetadata()`):
- Store artwork URL in `currentArtworkUrl`
- Reset bitmap to null
- Start playback immediately (non-blocking)
2. **Async Download** (Background Coroutine):
- Check cache: instant hit if available
- Network miss: download, scale, cache
- Auto-retry on network failure with exponential backoff
- Graceful fallback if artwork unavailable
3. **Notification Update**:
- Pass bitmap to `updatePlaybackServiceNotification()`
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
- Display as large icon in notification
- Show on lock screen
**Memory Management:**
| Metric | Value |
|--------|-------|
| Cache Size | 1/8 of heap (12-16MB typical) |
| Max Resolution | 512x512 pixels |
| Capacity | ~50-100 album arts |
| Eviction Policy | LRU (Least Recently Used) |
| Lifetime | In-memory only (app session) |
| Network Timeout | 5 seconds per download |
**Performance Characteristics:**
- **Cache Hit**: ~1ms (in-memory retrieval)
- **Cache Miss**: ~200-500ms (download + scale)
- **Playback Impact**: Zero (async downloads)
- **Memory Overhead**: Max 16MB (auto-eviction)
- **Error Recovery**: Automatic with exponential backoff
## Backend Initialization
**Location**: `src-tauri/src/lib.rs`
Backend selection is platform-specific:
```rust
fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
#[cfg(target_os = "linux")]
{
match MpvBackend::new(event_emitter.clone()) {
Ok(backend) => return Box::new(backend),
Err(e) => eprintln!("MPV init failed: {}", e),
}
}
#[cfg(target_os = "android")]
{
// ExoPlayer requires Activity context, initialized separately
}
// Fallback
Box::new(NullBackend::new())
}
```

View File

@ -0,0 +1,287 @@
# Download Manager & Offline Architecture
## Overview
**Location**: `src-tauri/src/download/`
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
```mermaid
flowchart TB
subgraph Frontend["Frontend"]
DownloadButton["DownloadButton.svelte"]
DownloadsPage["/downloads"]
DownloadsStore["downloads.ts store"]
end
subgraph Backend["Rust Backend"]
Commands["Download Commands"]
DownloadManager["DownloadManager"]
DownloadWorker["DownloadWorker"]
SmartCache["SmartCache Engine"]
end
subgraph Storage["Storage"]
SQLite[("SQLite DB")]
MediaFiles[("Downloaded Files")]
end
DownloadButton -->|"invoke('download_item')"| Commands
DownloadsPage -->|"invoke('get_downloads')"| Commands
Commands --> DownloadManager
DownloadManager --> DownloadWorker
DownloadManager --> SmartCache
DownloadWorker -->|"HTTP Stream"| MediaFiles
DownloadWorker -->|"Events"| DownloadsStore
Commands <--> SQLite
SmartCache <--> SQLite
```
## Download Worker
**Location**: `src-tauri/src/download/worker.rs`
The download worker handles HTTP streaming with retry logic and resume support:
```rust
pub struct DownloadWorker {
client: reqwest::Client,
max_retries: u32,
}
pub struct DownloadTask {
pub id: i64,
pub item_id: String,
pub user_id: String,
pub priority: i32,
pub url: String,
pub target_path: PathBuf,
pub mime_type: Option<String>,
pub expected_size: Option<i64>,
}
```
**Retry Strategy**:
- Exponential backoff: 5s, 15s, 45s
- Maximum 3 retry attempts
- HTTP Range requests for resume support
- Progress events emitted every 1MB
**Download Flow**:
```mermaid
sequenceDiagram
participant UI
participant Command as download_item
participant DB as SQLite
participant Worker as DownloadWorker
participant Jellyfin as Jellyfin Server
participant Store as downloads store
UI->>Command: download_item(itemId, userId)
Command->>DB: INSERT INTO downloads
Command->>Worker: Start download task
Worker->>Jellyfin: GET /Items/{id}/Download
loop Progress Updates
Jellyfin->>Worker: Stream chunks
Worker->>Worker: Write to .part file
Worker->>Store: Emit progress event
Store->>UI: Update progress bar
end
Worker->>Worker: Rename .part to final
Worker->>DB: UPDATE status='completed'
Worker->>Store: Emit completed event
Store->>UI: Show completed
```
## Smart Caching Engine
**Location**: `src-tauri/src/download/cache.rs`
The smart caching system provides predictive downloads based on listening patterns:
```rust
pub struct SmartCache {
config: Arc<Mutex<CacheConfig>>,
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
}
pub struct CacheConfig {
pub queue_precache_enabled: bool,
pub queue_precache_count: usize, // Default: 5
pub album_affinity_enabled: bool,
pub album_affinity_threshold: usize, // Default: 3
pub storage_limit: u64, // Default: 10GB
pub wifi_only: bool, // Default: true
}
```
**Caching Strategies**:
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
```mermaid
flowchart TB
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
Play --> TrackHistory["Track Play History"]
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
CacheAlbum --> CheckStorage
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
CheckStorage -->|"OK"| Download["Queue Download"]
```
## Download Commands
**Location**: `src-tauri/src/commands/download.rs`
| Command | Parameters | Description |
|---------|------------|-------------|
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
| `download_album` | `album_id, user_id` | Queue all tracks in album |
| `get_downloads` | `user_id, status_filter` | Get download list |
| `pause_download` | `download_id` | Pause active download |
| `resume_download` | `download_id` | Resume paused download |
| `cancel_download` | `download_id` | Cancel and delete partial |
| `delete_download` | `download_id` | Delete completed download |
## Offline Commands
**Location**: `src-tauri/src/commands/offline.rs`
| Command | Parameters | Description |
|---------|------------|-------------|
| `offline_is_available` | `item_id` | Check if item downloaded |
| `offline_get_items` | `user_id` | Get all offline items |
| `offline_search` | `user_id, query` | Search downloaded items |
## Player Integration
**Location**: `src-tauri/src/commands/player.rs` (modified)
The player checks for local downloads before streaming:
```rust
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
let local_path = db.and_then(|db_wrapper| {
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
});
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(jellyfin_id.clone())
}
} else {
MediaSource::Remote {
stream_url: req.stream_url,
jellyfin_item_id: jellyfin_id.clone()
}
};
MediaItem { source, /* ... */ }
}
```
## Frontend Downloads Store
**Location**: `src/lib/stores/downloads.ts`
```typescript
interface DownloadsState {
downloads: Record<number, DownloadInfo>;
activeCount: number;
queuedCount: number;
}
const downloads = createDownloadsStore();
// Actions
downloads.downloadItem(itemId, userId, filePath)
downloads.downloadAlbum(albumId, userId)
downloads.pause(downloadId)
downloads.resume(downloadId)
downloads.cancel(downloadId)
downloads.delete(downloadId)
downloads.refresh(userId, statusFilter)
// Derived stores
export const activeDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'downloading')
);
```
**Event Handling**:
The store listens to Tauri events for real-time updates:
```typescript
listen<DownloadEvent>('download-event', (event) => {
const payload = event.payload;
switch (payload.type) {
case 'started':
// Update status to 'downloading'
case 'progress':
// Update progress and bytes_downloaded
case 'completed':
// Update status to 'completed', progress to 1.0
case 'failed':
// Update status to 'failed', store error message
}
});
```
## Download UI Components
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
- Multiple states: available, downloading, completed, failed, paused
- Circular progress ring during download
- Size variants: sm, md, lg
- Integrated into TrackList with `showDownload={true}` prop
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
- Individual download list item with progress bar
- Action buttons: pause, resume, cancel, delete
- Status indicators with color coding
**Downloads Page** (`src/routes/downloads/+page.svelte`):
- Active/Completed tabs
- Bulk actions: Pause All, Resume All, Clear Completed
- Empty states with helpful instructions
## Database Schema
**downloads table**:
```sql
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
user_id TEXT NOT NULL,
file_path TEXT,
file_size INTEGER,
mime_type TEXT,
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
progress REAL DEFAULT 0.0,
bytes_downloaded INTEGER DEFAULT 0,
priority INTEGER DEFAULT 0,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT
);
CREATE INDEX idx_downloads_queue
ON downloads(status, priority DESC, queued_at ASC)
WHERE status IN ('pending', 'downloading');
```

View File

@ -0,0 +1,92 @@
# Connectivity & Network Architecture
## HTTP Client with Retry Logic
**Location**: `src-tauri/src/jellyfin/http_client.rs`
The HTTP client provides automatic retry with exponential backoff for network resilience:
```rust
pub struct HttpClient {
client: reqwest::Client,
config: HttpConfig,
}
pub struct HttpConfig {
pub base_url: String,
pub timeout: Duration, // Default: 10s
pub max_retries: u32, // Default: 3
}
```
**Retry Strategy:**
- Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors
- No retry on: 4xx client errors, 401/403 authentication errors
**Error Classification:**
```rust
pub enum ErrorKind {
Network, // Connection failures, timeouts, DNS errors
Authentication, // 401/403 responses
Server, // 5xx server errors
Client, // Other 4xx errors
}
```
## Connectivity Monitor
**Location**: `src-tauri/src/connectivity/mod.rs`
The connectivity monitor tracks server reachability with adaptive polling:
```mermaid
flowchart TB
Monitor["ConnectivityMonitor"] --> Poller["Background Task"]
Poller --> Check{"Server<br/>Reachable?"}
Check -->|"Yes"| Online["30s Interval"]
Check -->|"No"| Offline["5s Interval"]
Online --> Emit["Emit Events"]
Offline --> Emit
Emit --> Frontend["Frontend Store"]
```
**Features:**
- **Adaptive Polling**: 30s when online, 5s when offline (for quick reconnection detection)
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events
- **Manual Marking**: Can mark reachable/unreachable based on API call results
- **Thread-Safe**: Uses Arc<RwLock<>> for shared state
**Tauri Commands:**
| Command | Description |
|---------|-------------|
| `connectivity_check_server` | Manual reachability check |
| `connectivity_set_server_url` | Update monitored server URL |
| `connectivity_get_status` | Get current connectivity status |
| `connectivity_start_monitoring` | Start background monitoring |
| `connectivity_stop_monitoring` | Stop monitoring |
| `connectivity_mark_reachable` | Mark server as reachable (after successful API call) |
| `connectivity_mark_unreachable` | Mark server as unreachable (after failed API call) |
**Frontend Integration:**
```typescript
// TypeScript store listens to Rust events
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
updateConnectivityState(event.payload.isReachable);
});
```
## Network Resilience Architecture
The connectivity system provides resilience through multiple layers:
1. **HTTP Client Layer**: Automatic retry with exponential backoff
2. **Connectivity Monitoring**: Background reachability checks
3. **Frontend Integration**: Offline mode detection and UI updates
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
**Design Principles:**
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication)
- **Fail Slow**: Retry network and 5xx errors with increasing delays
- **Adaptive Polling**: Reduce polling frequency when online, increase when offline
- **Event-Driven**: Frontend reacts to connectivity changes via events

View File

@ -0,0 +1,614 @@
# Offline Database Design
## Entity Relationship Diagram
```mermaid
erDiagram
servers ||--o{ users : "has"
servers ||--o{ libraries : "has"
libraries ||--o{ items : "contains"
items ||--o{ items : "parent_of"
items ||--o{ user_data : "has"
items ||--o{ downloads : "has"
items ||--o{ media_streams : "has"
items ||--o{ thumbnails : "has"
users ||--o{ user_data : "owns"
users ||--o{ downloads : "owns"
users ||--o{ sync_queue : "owns"
servers {
int id PK
string jellyfin_id UK
string name
string url
string version
datetime last_sync
}
users {
int id PK
string jellyfin_id
int server_id FK
string name
boolean is_active
}
libraries {
int id PK
string jellyfin_id
int server_id FK
string name
string collection_type
string image_tag
}
items {
int id PK
string jellyfin_id
int server_id FK
int library_id FK
int parent_id FK
string type
string name
string sort_name
string overview
int production_year
float community_rating
string official_rating
int runtime_ticks
string primary_image_tag
string backdrop_image_tag
string album_id
string album_name
string album_artist
json artists
json genres
int index_number
int parent_index_number
string premiere_date
json metadata_json
datetime created_at
datetime updated_at
datetime last_sync
}
user_data {
int id PK
int item_id FK
int user_id FK
int position_ticks
int play_count
boolean is_favorite
boolean played
datetime last_played
datetime updated_at
datetime synced_at
}
downloads {
int id PK
int item_id FK
int user_id FK
string file_path
int file_size
string status
float progress
int priority
string error_message
datetime created_at
datetime completed_at
}
media_streams {
int id PK
int item_id FK
int stream_index
string type
string codec
string language
string display_title
boolean is_default
boolean is_forced
boolean is_external
}
sync_queue {
int id PK
int user_id FK
string operation
string entity_type
string entity_id
json payload
datetime created_at
int attempts
datetime last_attempt
string status
}
thumbnails {
int id PK
int item_id FK
string image_type
string image_tag
string file_path
int width
int height
datetime cached_at
}
```
## Table Definitions
### servers
Stores connected Jellyfin server information.
```sql
CREATE TABLE servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
url TEXT NOT NULL,
version TEXT,
last_sync DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### users
Stores user accounts per server. Access tokens are stored separately in secure storage (see [09-security.md](09-security.md)).
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
is_active BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(jellyfin_id, server_id)
);
```
### libraries
Stores library/collection metadata.
```sql
CREATE TABLE libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
collection_type TEXT,
image_tag TEXT,
sort_order INTEGER DEFAULT 0,
last_sync DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(jellyfin_id, server_id)
);
CREATE INDEX idx_libraries_server ON libraries(server_id);
```
### items
Main table for all media items (movies, episodes, albums, songs, etc.).
```sql
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
library_id INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
parent_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
-- Basic metadata
type TEXT NOT NULL,
name TEXT NOT NULL,
sort_name TEXT,
overview TEXT,
-- Media info
production_year INTEGER,
community_rating REAL,
official_rating TEXT,
runtime_ticks INTEGER,
-- Images
primary_image_tag TEXT,
backdrop_image_tag TEXT,
-- Audio-specific
album_id TEXT,
album_name TEXT,
album_artist TEXT,
artists TEXT, -- JSON array
-- Series/Season-specific
index_number INTEGER,
parent_index_number INTEGER,
series_id TEXT,
series_name TEXT,
season_id TEXT,
-- Additional
genres TEXT, -- JSON array
premiere_date TEXT,
metadata_json TEXT,
-- Sync tracking
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_sync DATETIME,
UNIQUE(jellyfin_id, server_id)
);
-- Performance indexes
CREATE INDEX idx_items_server ON items(server_id);
CREATE INDEX idx_items_library ON items(library_id);
CREATE INDEX idx_items_parent ON items(parent_id);
CREATE INDEX idx_items_type ON items(type);
CREATE INDEX idx_items_album ON items(album_id);
CREATE INDEX idx_items_series ON items(series_id);
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
-- Full-text search
CREATE VIRTUAL TABLE items_fts USING fts5(
name,
overview,
artists,
album_name,
album_artist,
content='items',
content_rowid='id'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
END;
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
END;
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
END;
```
### media_streams
Stores subtitle and audio track information for items.
```sql
CREATE TABLE media_streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
stream_index INTEGER NOT NULL,
type TEXT NOT NULL,
codec TEXT,
language TEXT,
display_title TEXT,
is_default BOOLEAN DEFAULT 0,
is_forced BOOLEAN DEFAULT 0,
is_external BOOLEAN DEFAULT 0,
path TEXT,
UNIQUE(item_id, stream_index)
);
CREATE INDEX idx_media_streams_item ON media_streams(item_id);
```
### user_data
Stores per-user data for items (favorites, progress, play count).
```sql
CREATE TABLE user_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Playback state
position_ticks INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
played BOOLEAN DEFAULT 0,
last_played DATETIME,
-- User preferences
is_favorite BOOLEAN DEFAULT 0,
user_rating REAL,
-- Sync tracking
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME,
needs_sync BOOLEAN DEFAULT 0,
UNIQUE(item_id, user_id)
);
CREATE INDEX idx_user_data_item ON user_data(item_id);
CREATE INDEX idx_user_data_user ON user_data(user_id);
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
WHERE position_ticks > 0 AND played = 0;
```
### downloads
Tracks downloaded media files.
```sql
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT,
file_size INTEGER,
file_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
transcode_profile TEXT,
priority INTEGER DEFAULT 0,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
completed_at DATETIME,
expires_at DATETIME,
UNIQUE(item_id, user_id)
);
CREATE INDEX idx_downloads_status ON downloads(status);
CREATE INDEX idx_downloads_user ON downloads(user_id);
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
WHERE status IN ('pending', 'downloading');
```
### sync_queue
Stores mutations to sync back to server when online.
```sql
CREATE TABLE sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
operation TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT,
status TEXT DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 5,
last_attempt DATETIME,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
WHERE status = 'pending';
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
```
### thumbnails
Caches downloaded artwork.
```sql
CREATE TABLE thumbnails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
image_type TEXT NOT NULL,
image_tag TEXT,
file_path TEXT NOT NULL,
width INTEGER,
height INTEGER,
file_size INTEGER,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(item_id, image_type, width)
);
CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
```
### playlists (for local/synced playlists)
```sql
CREATE TABLE playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
is_local_only BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME,
needs_sync BOOLEAN DEFAULT 0
);
CREATE TABLE playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(playlist_id, item_id)
);
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
```
## Key Queries
### Get items for offline library browsing
```sql
-- Get all albums in a music library
SELECT * FROM items
WHERE library_id = ? AND type = 'MusicAlbum'
ORDER BY sort_name;
-- Get tracks for an album
SELECT * FROM items
WHERE album_id = ? AND type = 'Audio'
ORDER BY parent_index_number, index_number;
```
### Resume / Continue Watching
```sql
SELECT i.*, ud.position_ticks, ud.last_played
FROM items i
JOIN user_data ud ON ud.item_id = i.id
WHERE ud.user_id = ?
AND ud.position_ticks > 0
AND ud.played = 0
ORDER BY ud.last_played DESC
LIMIT 20;
```
### Offline search
```sql
SELECT i.* FROM items i
JOIN items_fts fts ON fts.rowid = i.id
WHERE items_fts MATCH ?
ORDER BY rank;
```
### Download queue management
```sql
-- Get next item to download
SELECT d.*, i.name, i.type
FROM downloads d
JOIN items i ON i.id = d.item_id
WHERE d.status = 'pending'
ORDER BY d.priority DESC, d.created_at ASC
LIMIT 1;
-- Get download progress for UI
SELECT
d.status,
COUNT(*) as count,
SUM(d.file_size) as total_size,
SUM(d.bytes_downloaded) as downloaded
FROM downloads d
WHERE d.user_id = ?
GROUP BY d.status;
```
### Sync queue processing
```sql
-- Get pending sync operations (oldest first)
SELECT * FROM sync_queue
WHERE status = 'pending'
AND attempts < max_attempts
ORDER BY created_at ASC
LIMIT 10;
-- Mark operation complete
UPDATE sync_queue
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
WHERE id = ?;
```
## Data Flow
### Online Mode
```mermaid
flowchart TB
subgraph OnlineMode["Online Mode"]
JellyfinServer["Jellyfin Server"]
OnlineRepo["OnlineRepo"]
SQLite["SQLite"]
HybridRepo["HybridRepository"]
UI["UI / Stores"]
JellyfinServer -->|"API Response"| OnlineRepo
OnlineRepo -->|"Cache"| SQLite
SQLite -->|"Sync"| JellyfinServer
OnlineRepo -->|"Response"| HybridRepo
SQLite -->|"Fallback"| HybridRepo
HybridRepo --> UI
end
```
### Offline Mode
```mermaid
flowchart TB
subgraph OfflineMode["Offline Mode"]
OfflineRepo["OfflineRepo"]
SQLite2["SQLite"]
SyncQueue["sync_queue<br/>(Queued for later)"]
HybridRepo2["HybridRepository"]
UI2["UI / Stores"]
OfflineRepo <-->|"Query"| SQLite2
SQLite2 -->|"Mutations"| SyncQueue
OfflineRepo --> HybridRepo2
HybridRepo2 --> UI2
end
```
### Sync on Reconnect
```mermaid
flowchart LR
NetworkRestored["Network restored"]
SyncService["SyncService"]
SyncQueue2["sync_queue"]
JellyfinAPI["Jellyfin API"]
MarkSynced["Mark synced"]
NetworkRestored --> SyncService
SyncService -->|"Read"| SyncQueue2
SyncQueue2 -->|"Send"| JellyfinAPI
JellyfinAPI -->|"Success"| MarkSynced
MarkSynced --> SyncService
```
## Storage Estimates
| Content Type | Metadata Size | Thumbnail Size | Media Size |
|--------------|---------------|----------------|------------|
| Song | ~2 KB | ~50 KB (300px) | 5-15 MB |
| Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB |
| Movie | ~5 KB | ~200 KB | 1-8 GB |
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
## Rust Module Structure
```
src-tauri/src/storage/
├── mod.rs # Module exports, Database struct
├── schema.rs # Table definitions, migrations
├── models.rs # Rust structs matching tables
├── queries/
│ ├── mod.rs
│ ├── items.rs # Item CRUD operations
│ ├── user_data.rs # User data operations
│ ├── downloads.rs # Download queue operations
│ └── sync.rs # Sync queue operations
└── sync/
├── mod.rs # SyncService
├── manager.rs # Background sync manager
└── operations.rs # Individual sync operation handlers
```

View File

@ -0,0 +1,69 @@
# Security
## Authentication Token Storage
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
```mermaid
flowchart TB
LoginSuccess["Login Success"]
KeyringCheck{"System Keyring<br/>Available?"}
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
LoginSuccess --> KeyringCheck
KeyringCheck -->|"Yes"| OSCredential
KeyringCheck -->|"No"| EncryptedFallback
```
**Key Format:**
```
jellytau::{server_id}::{user_id}::access_token
```
**Rationale:**
- Tokens in SQLite would be readable if the database file is accessed
- System keyrings provide OS-level encryption and access control
- Fallback ensures functionality on minimal systems without a keyring daemon
## Secure Storage Module
**Location**: `src-tauri/src/secure_storage/` (planned)
```rust
pub trait SecureStorage: Send + Sync {
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
}
// Platform implementations
pub struct KeyringStorage; // Uses keyring crate
pub struct EncryptedFileStorage; // AES-256-GCM fallback
```
## Network Security
| Aspect | Implementation |
|--------|----------------|
| Transport | HTTPS required for all Jellyfin API calls |
| Certificate Validation | System CA store (configurable for self-signed) |
| Token Transmission | Bearer token in `Authorization` header only |
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
## Local Data Protection
| Data Type | Protection |
|-----------|------------|
| Access Tokens | System keyring or encrypted file |
| Database (SQLite) | Plaintext (metadata only, no secrets) |
| Downloaded Media | Filesystem permissions only |
| Cached Thumbnails | Filesystem permissions only |
## Security Considerations
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
2. **Token Isolation**: Each user/server combination has a separate token entry
3. **Logout Cleanup**: Token deletion from secure storage on logout
4. **No Token Logging**: Tokens are never written to logs or debug output
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution

View File

@ -10,6 +10,7 @@ pub mod offline;
pub mod playback_mode; pub mod playback_mode;
pub mod playback_reporting; pub mod playback_reporting;
pub mod player; pub mod player;
pub mod playlist;
pub mod repository; pub mod repository;
pub mod sessions; pub mod sessions;
pub mod storage; pub mod storage;
@ -25,6 +26,7 @@ pub use playback_mode::*;
#[allow(unused_imports)] // Used when playback_reporting is fully integrated #[allow(unused_imports)] // Used when playback_reporting is fully integrated
pub use playback_reporting::*; pub use playback_reporting::*;
pub use player::*; pub use player::*;
pub use playlist::*;
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper}; pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
pub use sessions::*; pub use sessions::*;
pub use storage::*; pub use storage::*;

View File

@ -2396,11 +2396,15 @@ pub async fn player_play_next_episode(
/// Handle playback ended event - triggers autoplay decision logic /// Handle playback ended event - triggers autoplay decision logic
/// This is called from: /// This is called from:
/// - Frontend when HTML5 video ends (Linux/desktop) /// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly /// - Android JNI callback also triggers this logic directly
#[tauri::command] #[tauri::command]
pub async fn player_on_playback_ended( pub async fn player_on_playback_ended(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
item_id: Option<String>,
repository_handle: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::player::autoplay::AutoplayDecision; use crate::player::autoplay::AutoplayDecision;
use crate::player::PlayerStatusEvent; use crate::player::PlayerStatusEvent;
@ -2408,9 +2412,24 @@ pub async fn player_on_playback_ended(
let controller_arc = player.0.clone(); let controller_arc = player.0.clone();
// Run autoplay decision logic // Run autoplay decision logic
// If item_id is provided (HTML5 video case), use the video-specific path
// that bypasses the backend queue and stale end_reason
let decision = { let decision = {
let controller = controller_arc.lock().await; let controller = controller_arc.lock().await;
controller.on_playback_ended().await? if let Some(ref id) = item_id {
// Video path: need repository to look up episode info
let repo = repository_handle
.as_ref()
.and_then(|handle| repository_manager.0.get(handle));
if let Some(repo) = repo {
controller.on_video_playback_ended(id, repo).await?
} else {
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
AutoplayDecision::Stop
}
} else {
controller.on_playback_ended().await?
}
}; };
// Handle the decision // Handle the decision
@ -2420,12 +2439,12 @@ pub async fn player_on_playback_ended(
let controller = controller_arc.lock().await; let controller = controller_arc.lock().await;
if let Some(emitter) = controller.event_emitter() { if let Some(emitter) = controller.event_emitter() {
// Emit StateChanged to idle to clear the current media from mini player // Emit StateChanged to idle to clear the current media from mini player
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
// (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
emitter.emit(PlayerStatusEvent::StateChanged { emitter.emit(PlayerStatusEvent::StateChanged {
state: "idle".to_string(), state: "idle".to_string(),
media_id: None, media_id: None,
}); });
// Also emit PlaybackEnded event
emitter.emit(PlayerStatusEvent::PlaybackEnded);
} }
} }
AutoplayDecision::AdvanceToNext => { AutoplayDecision::AdvanceToNext => {

View File

@ -0,0 +1,115 @@
//! Tauri commands for playlist management
//! Uses handle-based system: UUID -> Arc<HybridRepository>
//!
//! TRACES: UR-014 | JA-019, JA-020
use log::debug;
use tauri::State;
use crate::repository::{MediaRepository, types::*};
use super::repository::RepositoryManagerWrapper;
/// Create a new playlist
#[tauri::command]
pub async fn playlist_create(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
name: String,
item_ids: Option<Vec<String>>,
) -> Result<PlaylistCreatedResult, String> {
debug!("[PLAYLIST] create called: name={}", name);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
let ids = item_ids.unwrap_or_default();
repo.as_ref().create_playlist(&name, &ids)
.await
.map_err(|e| format!("{:?}", e))
}
/// Delete a playlist
#[tauri::command]
pub async fn playlist_delete(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
) -> Result<(), String> {
debug!("[PLAYLIST] delete called: id={}", playlist_id);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().delete_playlist(&playlist_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Rename a playlist
#[tauri::command]
pub async fn playlist_rename(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
name: String,
) -> Result<(), String> {
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().rename_playlist(&playlist_id, &name)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get playlist items with PlaylistItemId
#[tauri::command]
pub async fn playlist_get_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
) -> Result<Vec<PlaylistEntry>, String> {
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playlist_items(&playlist_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Add items to a playlist
#[tauri::command]
pub async fn playlist_add_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
item_ids: Vec<String>,
) -> Result<(), String> {
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
.await
.map_err(|e| format!("{:?}", e))
}
/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
#[tauri::command]
pub async fn playlist_remove_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
entry_ids: Vec<String>,
) -> Result<(), String> {
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
.await
.map_err(|e| format!("{:?}", e))
}
/// Move a playlist item to a new position
#[tauri::command]
pub async fn playlist_move_item(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
item_id: String,
new_index: u32,
) -> Result<(), String> {
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
.await
.map_err(|e| format!("{:?}", e))
}

View File

@ -224,20 +224,20 @@ impl JellyfinClient {
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}", debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id, item_ids.len(), start_index); session_id, item_ids.len(), start_index);
// Build URL with query parameters (Jellyfin expects query params, not JSON body!) // Build URL with query parameters (Jellyfin expects PascalCase query params)
let mut url = format!( let mut url = format!(
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}", "{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
self.config.server_url, session_id, start_index self.config.server_url, session_id, start_index
); );
// Add item IDs as repeated query parameters // Add item IDs as repeated query parameters
for item_id in &item_ids { for item_id in &item_ids {
url.push_str(&format!("&itemIds={}", item_id)); url.push_str(&format!("&ItemIds={}", item_id));
} }
// Add start position if provided // Add start position if provided
if let Some(ticks) = start_position_ticks { if let Some(ticks) = start_position_ticks {
url.push_str(&format!("&startPositionTicks={}", ticks)); url.push_str(&format!("&StartPositionTicks={}", ticks));
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks); log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
} }

View File

@ -100,6 +100,9 @@ use commands::{
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_person, repository_get_items_by_person, repository_get_similar_items,
// Playlist commands
playlist_create, playlist_delete, playlist_rename, playlist_get_items,
playlist_add_items, playlist_remove_items, playlist_move_item,
// Conversion commands // Conversion commands
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds, format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
calc_progress, convert_percent_to_volume, calc_progress, convert_percent_to_volume,
@ -769,6 +772,14 @@ pub fn run() {
repository_get_person, repository_get_person,
repository_get_items_by_person, repository_get_items_by_person,
repository_get_similar_items, repository_get_similar_items,
// Playlist commands
playlist_create,
playlist_delete,
playlist_rename,
playlist_get_items,
playlist_add_items,
playlist_remove_items,
playlist_move_item,
// Conversion commands // Conversion commands
format_time_seconds, format_time_seconds,
format_time_seconds_long, format_time_seconds_long,

View File

@ -770,8 +770,18 @@ impl PlayerController {
} }
// For video episodes, fetch next episode and show popup // For video episodes, fetch next episode and show popup
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await { if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
if let Some(next_ep) = self.fetch_next_episode_for_item(&current).await? { let repo = self.repository.lock().unwrap().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
} else {
debug!("[PlayerController] No repository available for audio-path episode lookup");
None
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock().unwrap().clone(); let settings = self.autoplay_settings.lock().unwrap().clone();
// Check if auto-play episode limit is reached // Check if auto-play episode limit is reached
@ -806,6 +816,78 @@ impl PlayerController {
} }
} }
/// Handle video playback ended from HTML5 video element.
///
/// HTML5 video plays independently of the Rust backend, so the backend
/// queue has no knowledge of the video item. This method bypasses the
/// queue lookup and end_reason check, using the provided Jellyfin item ID
/// to look up the item and check for next episodes.
pub async fn on_video_playback_ended(
&self,
item_id: &str,
repo: Arc<dyn crate::repository::MediaRepository>,
) -> Result<AutoplayDecision, String> {
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
let stale_reason = self.take_end_reason();
if stale_reason.is_some() {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
}
debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
// Check sleep timer state
let timer_mode = {
let timer = self.sleep_timer.lock().unwrap();
timer.mode.clone()
};
match &timer_mode {
SleepTimerMode::Time { end_time } => {
let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at video end");
self.sleep_timer.lock().unwrap().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
self.sleep_timer.lock().unwrap().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
self.emit_sleep_timer_changed();
if should_stop {
return Ok(AutoplayDecision::Stop);
}
}
_ => {}
}
// Fetch next episode for the video that just ended
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
let settings = self.autoplay_settings.lock().unwrap().clone();
let limit_reached = self.increment_autoplay_count();
if limit_reached {
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode: next_ep.0,
next_episode: next_ep.1,
countdown_seconds: settings.countdown_seconds,
auto_advance: settings.enabled && !limit_reached,
});
}
// No next episode found
debug!("[PlayerController] No next episode found for {}", item_id);
Ok(AutoplayDecision::Stop)
}
/// Check if a media item is an episode (has Jellyfin ID to query) /// Check if a media item is an episode (has Jellyfin ID to query)
async fn is_episode_item(&self, item: &MediaItem) -> bool { async fn is_episode_item(&self, item: &MediaItem) -> bool {
// For now, assume video items are episodes // For now, assume video items are episodes
@ -813,34 +895,63 @@ impl PlayerController {
item.media_type == MediaType::Video item.media_type == MediaType::Video
} }
/// Fetch next episode for a series (using Repository) /// Fetch next episode for a series by looking up the season's episodes
async fn fetch_next_episode_for_item(&self, current: &MediaItem) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> { /// sorted by index number and picking the one after the current episode.
let repo = self.repository.lock().unwrap().clone(); ///
let Some(repo) = repo else { /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
return Ok(None); /// (which relies on watch history that may not be updated yet due to
}; /// the async nature of playback progress reporting).
async fn fetch_next_episode_for_item(
&self,
item_id: &str,
repo: &Arc<dyn crate::repository::MediaRepository>,
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
use crate::repository::types::GetItemsOptions;
let jellyfin_id = current.jellyfin_id() // Get the current item details from repository
.ok_or_else(|| "No Jellyfin ID for current item".to_string())?; let current_repo_item = repo.get_item(item_id)
// First, get the current item details from repository
let current_repo_item = repo.get_item(jellyfin_id)
.await .await
.map_err(|e| format!("Failed to get current item: {}", e))?; .map_err(|e| format!("Failed to get current item: {}", e))?;
let series_id = current_repo_item.series_id.clone() // Need season_id to fetch sibling episodes
.ok_or_else(|| "Current item is not an episode".to_string())?; let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(),
// Fetch next up episodes for this series None => {
let next_episodes = repo.get_next_up_episodes(Some(&series_id), Some(1)) debug!("[PlayerController] Current item has no season_id, cannot find next episode");
.await return Ok(None);
.map_err(|e| format!("Failed to fetch next episodes: {}", e))?;
if let Some(next) = next_episodes.first() {
// Verify it's not the same episode
if next.id != current_repo_item.id {
return Ok(Some((current_repo_item, next.clone())));
} }
};
// Fetch all episodes in the season sorted by episode number
let options = GetItemsOptions {
sort_by: Some("IndexNumber".to_string()),
sort_order: Some("Ascending".to_string()),
limit: Some(500),
include_item_types: Some(vec!["Episode".to_string()]),
..Default::default()
};
let result = repo.get_items(&season_id, Some(options))
.await
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
// Sort client-side by index_number to ensure correct ordering
// (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
// Find the current episode by ID and return the next one
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1];
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
return Ok(Some((current_repo_item, next.clone())));
} else {
debug!("[PlayerController] Current episode is the last in the season");
}
} else {
debug!("[PlayerController] Current episode not found in season episodes");
} }
Ok(None) Ok(None)

View File

@ -414,6 +414,107 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await self.parallel_race(cache_future, server_future).await
} }
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
// Write operation - delegate directly to server
self.online.create_playlist(name, item_ids).await
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.delete_playlist(playlist_id).await
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.rename_playlist(playlist_id, name).await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let offline = Arc::clone(&self.offline);
let offline_for_save = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let playlist_id = playlist_id.to_string();
let playlist_id_clone = playlist_id.clone();
let playlist_id_for_save = playlist_id.clone();
let cache_future = self.cache_with_timeout(async move {
offline.get_playlist_items(&playlist_id).await
});
let server_future = async move {
online.get_playlist_items(&playlist_id_clone).await
};
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
let cache_had_content = cache_result.as_ref()
.map(|data| data.has_content())
.unwrap_or(false);
if cache_had_content {
// If server also succeeded, update cache in background
if let Ok(server_entries) = server_result {
tokio::spawn(async move {
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await {
warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
}
});
}
return cache_result;
}
// Cache miss - use server result
match server_result {
Ok(entries) => {
let entries_clone = entries.clone();
tokio::spawn(async move {
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await {
warn!("[HybridRepo] Failed to save playlist items to cache: {:?}", e);
}
});
Ok(entries)
}
Err(e) => cache_result.or(Err(e)),
}
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.add_to_playlist(playlist_id, item_ids).await
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.remove_from_playlist(playlist_id, entry_ids).await
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.move_playlist_item(playlist_id, item_id, new_index).await
}
} }
#[cfg(test)] #[cfg(test)]
@ -562,6 +663,34 @@ mod tests {
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> { async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
unimplemented!() unimplemented!()
} }
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
unimplemented!()
}
} }
/// Mock online repository that returns predefined items /// Mock online repository that returns predefined items
@ -691,6 +820,34 @@ mod tests {
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> { async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
unimplemented!() unimplemented!()
} }
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
unimplemented!()
}
} }
fn create_test_item(id: &str, name: &str) -> MediaItem { fn create_test_item(id: &str, name: &str) -> MediaItem {

View File

@ -191,4 +191,68 @@ pub trait MediaRepository: Send + Sync {
item_id: &str, item_id: &str,
limit: Option<usize>, limit: Option<usize>,
) -> Result<SearchResult, RepoError>; ) -> Result<SearchResult, RepoError>;
// ===== Playlist Methods =====
/// Create a new playlist on the server
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError>;
/// Delete a playlist
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
/// Rename a playlist
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
/// Get playlist items with PlaylistItemId (needed for remove/reorder)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError>;
/// Add items to a playlist
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-020 - Add/remove items from playlist
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError>;
/// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-020 - Add/remove items from playlist
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError>;
/// Move a playlist item to a new position
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-020 - Add/remove items from playlist
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError>;
} }

View File

@ -346,6 +346,56 @@ impl OfflineRepository {
Ok(count) Ok(count)
} }
/// Cache playlist items from server into local database
/// Called by HybridRepository after fetching from online
pub async fn save_playlist_items_to_cache(
&self,
playlist_id: &str,
entries: &[PlaylistEntry],
) -> Result<(), RepoError> {
let playlist_id = playlist_id.to_string();
let user_id = self.user_id.clone();
let entries: Vec<(String, String, usize)> = entries
.iter()
.enumerate()
.map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
.collect();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Ensure playlist record exists
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
))?;
// Clear existing entries and re-insert
tx.execute(Query::with_params(
"DELETE FROM playlist_items WHERE playlist_id = ?",
vec![QueryParam::String(playlist_id.clone())],
))?;
for (_, item_id, sort_order) in &entries {
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
vec![
QueryParam::String(playlist_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::Int(*sort_order as i32),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to cache playlist items: {}", e),
})
}
} }
#[async_trait] #[async_trait]
@ -1088,6 +1138,254 @@ impl MediaRepository for OfflineRepository {
// Similar items require server-side computation and are not available offline // Similar items require server-side computation and are not available offline
Err(RepoError::Offline) Err(RepoError::Offline)
} }
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
let playlist_id = uuid::Uuid::new_v4().to_string();
let user_id = self.user_id.clone();
let name = name.to_string();
let item_ids = item_ids.to_vec();
let pid = playlist_id.clone();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
tx.execute(Query::with_params(
"INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
))?;
for (i, item_id) in item_ids.iter().enumerate() {
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to create playlist: {}", e),
})?;
Ok(PlaylistCreatedResult { id: playlist_id })
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
let query = Query::with_params(
"DELETE FROM playlists WHERE id = ?",
vec![QueryParam::String(playlist_id.to_string())],
);
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
message: format!("Failed to delete playlist: {}", e),
})?;
Ok(())
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
let query = Query::with_params(
"UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![
QueryParam::String(name.to_string()),
QueryParam::String(playlist_id.to_string()),
],
);
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
message: format!("Failed to rename playlist: {}", e),
})?;
Ok(())
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let query = Query::with_params(
"SELECT pi.id, \
i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
i.parent_index_number \
FROM playlist_items pi \
JOIN items i ON pi.item_id = i.id \
WHERE pi.playlist_id = ? \
ORDER BY pi.sort_order ASC",
vec![QueryParam::String(playlist_id.to_string())],
);
let items = self.db_service
.query_many(query, |row| {
let entry_id: i64 = row.get(0)?;
// Columns offset by 1 because first column is pi.id
let cached = CachedItem {
id: row.get(1)?,
name: row.get(2)?,
item_type: row.get(3)?,
server_id: row.get(4)?,
parent_id: row.get(5)?,
library_id: row.get(6)?,
overview: row.get(7)?,
genres: row.get(8)?,
runtime_ticks: row.get(9)?,
production_year: row.get(10)?,
community_rating: row.get(11)?,
official_rating: row.get(12)?,
primary_image_tag: row.get(13)?,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: row.get(14)?,
album_name: row.get(15)?,
album_artist: row.get(16)?,
artists: row.get(17)?,
index_number: row.get(18)?,
series_id: row.get(19)?,
series_name: row.get(20)?,
season_id: row.get(21)?,
season_name: row.get(22)?,
parent_index_number: row.get(23)?,
};
Ok((entry_id.to_string(), cached))
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to get playlist items: {}", e),
})?;
Ok(items
.into_iter()
.map(|(entry_id, cached)| PlaylistEntry {
playlist_item_id: entry_id,
item: Self::cached_item_to_media_item(cached, None),
})
.collect())
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
// Get current max sort_order
let max_query = Query::with_params(
"SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
vec![QueryParam::String(playlist_id.to_string())],
);
let max_order: i32 = self.db_service
.query_one(max_query, |row| row.get(0))
.await
.unwrap_or(-1);
let playlist_id = playlist_id.to_string();
let item_ids = item_ids.to_vec();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
for (i, item_id) in item_ids.iter().enumerate() {
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
vec![
QueryParam::String(playlist_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::Int(max_order + 1 + i as i32),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to add items to playlist: {}", e),
})?;
Ok(())
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
let playlist_id = playlist_id.to_string();
let entry_ids = entry_ids.to_vec();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
for entry_id in &entry_ids {
tx.execute(Query::with_params(
"DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to remove items from playlist: {}", e),
})?;
Ok(())
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
let playlist_id = playlist_id.to_string();
let item_id = item_id.to_string();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Get all items ordered by sort_order
let items: Vec<(i64, String)> = tx.query_many(
Query::with_params(
"SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
vec![QueryParam::String(playlist_id)],
),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
// Find the item to move
let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
if let Some(old_pos) = old_idx {
let mut ids = items;
let entry = ids.remove(old_pos);
let insert_at = (new_index as usize).min(ids.len());
ids.insert(insert_at, entry);
// Renumber all sort_orders
for (i, (entry_id, _)) in ids.iter().enumerate() {
tx.execute(Query::with_params(
"UPDATE playlist_items SET sort_order = ? WHERE id = ?",
vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
))?;
}
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to move playlist item: {}", e),
})?;
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
@ -1153,6 +1451,27 @@ mod tests {
playback_context_id TEXT, playback_context_id TEXT,
PRIMARY KEY (user_id, item_id) PRIMARY KEY (user_id, item_id)
); );
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_local INTEGER DEFAULT 0,
jellyfin_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
CREATE TABLE playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(playlist_id, item_id)
);
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
"#).unwrap(); "#).unwrap();
// Insert a test server // Insert a test server
@ -1330,4 +1649,244 @@ mod tests {
assert!(result.is_ok(), "Simple case should work: {:?}", result); assert!(result.is_ok(), "Simple case should work: {:?}", result);
assert_eq!(result.unwrap(), 3); assert_eq!(result.unwrap(), 3);
} }
// ===== Playlist Tests =====
/// Helper to seed items into the DB for playlist tests
async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
let items: Vec<MediaItem> = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect();
repo.save_to_cache("library-1", &items).await.unwrap();
}
#[tokio::test]
async fn test_playlist_create_empty() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
let result = repo.create_playlist("My Playlist", &[]).await;
assert!(result.is_ok());
let created = result.unwrap();
assert!(!created.id.is_empty(), "Should return a non-empty playlist ID");
// Verify playlist exists in DB
let name: String = db_service
.query_one(
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(name, "My Playlist");
}
#[tokio::test]
async fn test_playlist_create_with_items() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1", "t2", "t3"]).await;
let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0].item.id, "t1");
assert_eq!(items[1].item.id, "t2");
assert_eq!(items[2].item.id, "t3");
}
#[tokio::test]
async fn test_playlist_delete() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1"]).await;
let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap();
// Delete it
repo.delete_playlist(&created.id).await.unwrap();
// Verify playlist is gone
let count: i32 = db_service
.query_one(
Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(count, 0);
// Verify cascade deleted playlist_items
let item_count: i32 = db_service
.query_one(
Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(item_count, 0);
}
#[tokio::test]
async fn test_playlist_rename() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
let created = repo.create_playlist("Original Name", &[]).await.unwrap();
repo.rename_playlist(&created.id, "New Name").await.unwrap();
let name: String = db_service
.query_one(
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(name, "New Name");
}
#[tokio::test]
async fn test_playlist_get_items_preserves_order() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c"]).await;
let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
// Order should match insertion order: c, a, b
assert_eq!(items[0].item.id, "c");
assert_eq!(items[1].item.id, "a");
assert_eq!(items[2].item.id, "b");
// Each entry should have a unique playlist_item_id
assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
}
#[tokio::test]
async fn test_playlist_get_items_empty_playlist() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
let created = repo.create_playlist("Empty", &[]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert!(items.is_empty());
}
#[tokio::test]
async fn test_playlist_add_items() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1", "t2", "t3"]).await;
let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap();
// Add two more tracks
repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0].item.id, "t1");
assert_eq!(items[1].item.id, "t2");
assert_eq!(items[2].item.id, "t3");
}
#[tokio::test]
async fn test_playlist_add_duplicate_items_ignored() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1"]).await;
let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
// Try to add the same item again
repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)");
}
#[tokio::test]
async fn test_playlist_remove_items() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1", "t2", "t3"]).await;
let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
// Remove the middle track by its entry ID
let entry_id_to_remove = items[1].playlist_item_id.clone();
repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap();
let items_after = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items_after.len(), 2);
assert_eq!(items_after[0].item.id, "t1");
assert_eq!(items_after[1].item.id, "t3");
}
#[tokio::test]
async fn test_playlist_move_item_forward() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c", "d"]).await;
let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
// Move 'a' (index 0) to index 2: expect b, c, a, d
repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["b", "c", "a", "d"]);
}
#[tokio::test]
async fn test_playlist_move_item_backward() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c", "d"]).await;
let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
// Move 'd' (index 3) to index 0: expect d, a, b, c
repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["d", "a", "b", "c"]);
}
#[tokio::test]
async fn test_playlist_move_item_to_end() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c"]).await;
let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap();
// Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[tokio::test]
async fn test_playlist_move_nonexistent_item_is_noop() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b"]).await;
let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap();
// Move a nonexistent item - should not error, just no-op
repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b"]);
}
} }

View File

@ -257,6 +257,31 @@ struct ItemsResponse {
total_record_count: usize, total_record_count: usize,
} }
/// Jellyfin playlist creation response
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CreatePlaylistResponse {
id: String,
}
/// Jellyfin playlist items response — items include PlaylistItemId
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)]
struct PlaylistItemsResponse {
items: Vec<JellyfinPlaylistItem>,
total_record_count: usize,
}
/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinPlaylistItem {
playlist_item_id: String,
#[serde(flatten)]
item: JellyfinItem,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
struct JellyfinItem { struct JellyfinItem {
@ -1192,6 +1217,146 @@ impl MediaRepository for OnlineRepository {
total_record_count: response.total_record_count, total_record_count: response.total_record_count,
}) })
} }
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
let body = serde_json::json!({
"Name": name,
"Ids": item_ids,
"MediaType": "Audio",
"UserId": self.user_id,
});
let response: CreatePlaylistResponse =
self.post_json_response("/Playlists", &body).await?;
Ok(PlaylistCreatedResult { id: response.id })
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
let endpoint = format!("/Items/{}", playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
let endpoint = format!("/Items/{}", playlist_id);
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let endpoint = format!(
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
playlist_id, self.user_id
);
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
debug!(
"[OnlineRepo] Got {} playlist items for {}",
response.items.len(),
playlist_id
);
Ok(response
.items
.into_iter()
.map(|pi| PlaylistEntry {
playlist_item_id: pi.playlist_item_id,
item: pi.item.to_media_item(self.user_id.clone()),
})
.collect())
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Adding {} items to playlist {}",
item_ids.len(),
playlist_id
);
let ids_param = item_ids.join(",");
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
self.post_json(&endpoint, &serde_json::json!({})).await
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Removing {} entries from playlist {}",
entry_ids.len(),
playlist_id
);
let ids_param = entry_ids.join(",");
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Moving item {} in playlist {} to index {}",
item_id, playlist_id, new_index
);
let endpoint = format!(
"/Playlists/{}/Items/{}/Move/{}",
playlist_id, item_id, new_index
);
self.post_json(&endpoint, &serde_json::json!({})).await
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -331,6 +331,41 @@ impl MeaningfulContent for PlaybackInfo {
} }
} }
/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
/// needed for remove/reorder operations (distinct from the media item's ID)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistEntry {
/// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
pub playlist_item_id: String,
/// The underlying media item
#[serde(flatten)]
pub item: MediaItem,
}
/// Result of creating a playlist
///
/// @req: JA-019 - Get/create/update playlists
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistCreatedResult {
pub id: String,
}
impl MeaningfulContent for Vec<PlaylistEntry> {
fn has_content(&self) -> bool {
!self.is_empty()
}
}
impl MeaningfulContent for PlaylistCreatedResult {
fn has_content(&self) -> bool {
!self.id.is_empty()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -563,4 +598,103 @@ mod tests {
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field"); assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor"); assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
} }
#[test]
fn test_playlist_entry_serialization() {
let entry = PlaylistEntry {
playlist_item_id: "entry-abc-123".to_string(),
item: MediaItem {
id: "track1".to_string(),
name: "Test Track".to_string(),
item_type: "Audio".to_string(),
server_id: "server1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: Some(vec!["Artist One".to_string()]),
artist_items: None,
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: None,
media_streams: None,
media_sources: None,
people: None,
},
};
let json = serde_json::to_string(&entry).expect("Failed to serialize");
// playlistItemId is camelCase
assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
// Flattened MediaItem fields appear at top level
assert!(json.contains(r#""id":"track1""#));
assert!(json.contains(r#""name":"Test Track""#));
assert!(json.contains(r#""type":"Audio""#));
}
#[test]
fn test_playlist_created_result_serialization() {
let result = PlaylistCreatedResult {
id: "playlist-new-123".to_string(),
};
let json = serde_json::to_string(&result).expect("Failed to serialize");
assert!(json.contains(r#""id":"playlist-new-123""#));
}
#[test]
fn test_playlist_entry_meaningful_content() {
let empty: Vec<PlaylistEntry> = vec![];
assert!(!empty.has_content());
let non_empty = vec![PlaylistEntry {
playlist_item_id: "e1".to_string(),
item: MediaItem {
id: "1".to_string(),
name: "Track".to_string(),
item_type: "Audio".to_string(),
server_id: "s1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: None,
artist_items: None,
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: None,
media_streams: None,
media_sources: None,
people: None,
},
}];
assert!(non_empty.has_content());
}
} }

View File

@ -100,6 +100,13 @@ impl<'a> Transaction<'a> {
pub fn execute(&mut self, query: Query) -> DbResult<usize> { pub fn execute(&mut self, query: Query) -> DbResult<usize> {
execute_query(self.conn, query) execute_query(self.conn, query)
} }
pub fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
where
F: Fn(&Row) -> SqliteResult<T>,
{
query_many(self.conn, query, mapper)
}
} }
/// Rusqlite-based database service implementation /// Rusqlite-based database service implementation

View File

@ -402,6 +402,123 @@ describe("RepositoryClient", () => {
}); });
}); });
describe("Playlist Methods", () => {
beforeEach(async () => {
(invoke as any).mockResolvedValueOnce("test-handle-123");
await client.create("https://server.com", "user1", "token123", "server1");
});
it("should create a playlist", async () => {
const mockResult = { id: "playlist-001" };
(invoke as any).mockResolvedValueOnce(mockResult);
const result = await client.createPlaylist("My Playlist", ["track1", "track2"]);
expect(result).toEqual(mockResult);
expect(invoke).toHaveBeenCalledWith("playlist_create", {
handle: "test-handle-123",
name: "My Playlist",
itemIds: ["track1", "track2"],
});
});
it("should create a playlist without initial items", async () => {
const mockResult = { id: "playlist-002" };
(invoke as any).mockResolvedValueOnce(mockResult);
await client.createPlaylist("Empty Playlist");
expect(invoke).toHaveBeenCalledWith("playlist_create", {
handle: "test-handle-123",
name: "Empty Playlist",
itemIds: null,
});
});
it("should delete a playlist", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
await client.deletePlaylist("playlist-001");
expect(invoke).toHaveBeenCalledWith("playlist_delete", {
handle: "test-handle-123",
playlistId: "playlist-001",
});
});
it("should rename a playlist", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
await client.renamePlaylist("playlist-001", "New Name");
expect(invoke).toHaveBeenCalledWith("playlist_rename", {
handle: "test-handle-123",
playlistId: "playlist-001",
name: "New Name",
});
});
it("should get playlist items", async () => {
const mockItems = [
{ playlistItemId: "entry1", id: "track1", name: "Track 1", type: "Audio" },
{ playlistItemId: "entry2", id: "track2", name: "Track 2", type: "Audio" },
];
(invoke as any).mockResolvedValueOnce(mockItems);
const items = await client.getPlaylistItems("playlist-001");
expect(items).toEqual(mockItems);
expect(invoke).toHaveBeenCalledWith("playlist_get_items", {
handle: "test-handle-123",
playlistId: "playlist-001",
});
});
it("should add items to a playlist", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
await client.addToPlaylist("playlist-001", ["track3", "track4"]);
expect(invoke).toHaveBeenCalledWith("playlist_add_items", {
handle: "test-handle-123",
playlistId: "playlist-001",
itemIds: ["track3", "track4"],
});
});
it("should remove items from a playlist using entry IDs", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
await client.removeFromPlaylist("playlist-001", ["entry1", "entry2"]);
expect(invoke).toHaveBeenCalledWith("playlist_remove_items", {
handle: "test-handle-123",
playlistId: "playlist-001",
entryIds: ["entry1", "entry2"],
});
});
it("should move a playlist item", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
await client.movePlaylistItem("playlist-001", "track1", 3);
expect(invoke).toHaveBeenCalledWith("playlist_move_item", {
handle: "test-handle-123",
playlistId: "playlist-001",
itemId: "track1",
newIndex: 3,
});
});
it("should throw error if not initialized before playlist operations", async () => {
const newClient = new RepositoryClient();
await expect(newClient.getPlaylistItems("pl-1")).rejects.toThrow("Repository not initialized");
await expect(newClient.createPlaylist("test")).rejects.toThrow("Repository not initialized");
await expect(newClient.deletePlaylist("pl-1")).rejects.toThrow("Repository not initialized");
});
});
describe("Error Handling", () => { describe("Error Handling", () => {
it("should throw error if invoke fails", async () => { it("should throw error if invoke fails", async () => {
(invoke as any).mockRejectedValueOnce(new Error("Network error")); (invoke as any).mockRejectedValueOnce(new Error("Network error"));

View File

@ -15,6 +15,8 @@ import type {
ImageType, ImageType,
ImageOptions, ImageOptions,
Genre, Genre,
PlaylistEntry,
PlaylistCreatedResult,
} from "./types"; } from "./types";
/** /**
@ -305,6 +307,63 @@ export class RepositoryClient {
}); });
} }
// ===== Playlist Methods (via Rust) =====
async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> {
return invoke<PlaylistCreatedResult>("playlist_create", {
handle: this.ensureHandle(),
name,
itemIds: itemIds ?? null,
});
}
async deletePlaylist(playlistId: string): Promise<void> {
return invoke("playlist_delete", {
handle: this.ensureHandle(),
playlistId,
});
}
async renamePlaylist(playlistId: string, name: string): Promise<void> {
return invoke("playlist_rename", {
handle: this.ensureHandle(),
playlistId,
name,
});
}
async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> {
return invoke<PlaylistEntry[]>("playlist_get_items", {
handle: this.ensureHandle(),
playlistId,
});
}
async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> {
return invoke("playlist_add_items", {
handle: this.ensureHandle(),
playlistId,
itemIds,
});
}
async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> {
return invoke("playlist_remove_items", {
handle: this.ensureHandle(),
playlistId,
entryIds,
});
}
async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> {
return invoke("playlist_move_item", {
handle: this.ensureHandle(),
playlistId,
itemId,
newIndex,
});
}
// ===== Getters ===== // ===== Getters =====
get serverUrl(): string { get serverUrl(): string {

View File

@ -240,6 +240,15 @@ export interface Genre {
name: string; name: string;
} }
// Playlist types
export interface PlaylistEntry extends MediaItem {
playlistItemId: string;
}
export interface PlaylistCreatedResult {
id: string;
}
export interface GetItemsOptions { export interface GetItemsOptions {
startIndex?: number; startIndex?: number;
limit?: number; limit?: number;

View File

@ -77,6 +77,7 @@
// Touch/swipe handlers // Touch/swipe handlers
function handleTouchStart(e: TouchEvent) { function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX; touchStartX = e.touches[0].clientX;
touchEndX = e.touches[0].clientX;
isSwiping = true; isSwiping = true;
} }

View File

@ -0,0 +1,312 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { invoke } from "@tauri-apps/api/core";
import type { MediaItem, PlaylistEntry } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
import TrackList from "./TrackList.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { formatDuration } from "$lib/utils/duration";
interface Props {
playlist: MediaItem;
}
let { playlist }: Props = $props();
let entries = $state<PlaylistEntry[]>([]);
let loading = $state(true);
let editingName = $state(false);
let editName = $state("");
let showDeleteConfirm = $state(false);
// Extract MediaItem[] from PlaylistEntry[] for TrackList
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
const totalDuration = $derived(
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0)
);
onMount(() => {
loadPlaylistItems();
});
async function loadPlaylistItems() {
loading = true;
try {
const repo = auth.getRepository();
entries = await repo.getPlaylistItems(playlist.id);
} catch (e) {
console.error("[PlaylistDetail] Failed to load items:", e);
toast.error("Failed to load playlist items");
} finally {
loading = false;
}
}
async function handlePlayAll() {
if (entries.length === 0) return;
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id);
await invoke("player_play_tracks", {
repositoryHandle,
request: {
trackIds,
startIndex: 0,
shuffle: false,
context: {
type: "playlist",
playlistId: playlist.id,
playlistName: playlist.name,
},
},
});
} catch (e) {
console.error("[PlaylistDetail] Failed to play all:", e);
toast.error("Failed to play playlist");
}
}
async function handleShufflePlay() {
if (entries.length === 0) return;
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id);
await invoke("player_play_tracks", {
repositoryHandle,
request: {
trackIds,
startIndex: 0,
shuffle: true,
context: {
type: "playlist",
playlistId: playlist.id,
playlistName: playlist.name,
},
},
});
} catch (e) {
console.error("[PlaylistDetail] Failed to shuffle play:", e);
toast.error("Failed to shuffle playlist");
}
}
async function handleRename() {
const trimmed = editName.trim();
if (!trimmed || trimmed === playlist.name) {
editingName = false;
editName = playlist.name;
return;
}
try {
const repo = auth.getRepository();
await repo.renamePlaylist(playlist.id, trimmed);
playlist.name = trimmed;
toast.success("Playlist renamed");
} catch (e) {
console.error("[PlaylistDetail] Failed to rename:", e);
toast.error("Failed to rename playlist");
editName = playlist.name;
} finally {
editingName = false;
}
}
async function handleDelete() {
try {
const repo = auth.getRepository();
await repo.deletePlaylist(playlist.id);
toast.success("Playlist deleted");
goto("/library");
} catch (e) {
console.error("[PlaylistDetail] Failed to delete:", e);
toast.error("Failed to delete playlist");
} finally {
showDeleteConfirm = false;
}
}
async function handleRemoveTrack(entry: PlaylistEntry) {
try {
const repo = auth.getRepository();
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
toast.success("Track removed");
} catch (e) {
console.error("[PlaylistDetail] Failed to remove track:", e);
toast.error("Failed to remove track");
}
}
function handleRenameKeydown(e: KeyboardEvent) {
if (e.key === "Enter") handleRename();
if (e.key === "Escape") {
editingName = false;
editName = playlist.name;
}
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex gap-6 pt-4">
<!-- Playlist artwork -->
<div class="flex-shrink-0 w-48">
{#if playlist.primaryImageTag}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
maxWidth={400}
alt={playlist.name}
class="w-full rounded-lg shadow-lg"
/>
{:else}
<div class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center">
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 space-y-4">
<div>
{#if editingName}
<input
type="text"
bind:value={editName}
onkeydown={handleRenameKeydown}
onblur={handleRename}
class="text-3xl font-bold bg-transparent border-b-2 border-[var(--color-jellyfin)] text-white outline-none w-full"
/>
{:else}
<button
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
onclick={() => { editingName = true; editName = playlist.name; }}
title="Click to rename"
>
{playlist.name}
</button>
{/if}
<p class="text-sm text-gray-400 mt-1">
{entries.length} track{entries.length !== 1 ? "s" : ""}
{#if totalDuration > 0}
&middot; {formatDuration(totalDuration)}
{/if}
</p>
</div>
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
<button
onclick={handlePlayAll}
disabled={entries.length === 0}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play All
</button>
<button
onclick={handleShufflePlay}
disabled={entries.length === 0}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
</svg>
Shuffle
</button>
<button
onclick={() => showDeleteConfirm = true}
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
Delete
</button>
</div>
{#if playlist.overview}
<p class="text-gray-300 leading-relaxed max-w-2xl">{playlist.overview}</p>
{/if}
</div>
</div>
<!-- Tracks -->
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">Tracks</h2>
<TrackList
{tracks}
{loading}
showArtist={true}
showAlbum={true}
context={{ type: "playlist", playlistId: playlist.id, playlistName: playlist.name }}
/>
{#if !loading && entries.length > 0}
<div class="space-y-1 mt-4">
{#each entries as entry, i (entry.playlistItemId)}
<div class="flex items-center justify-end px-4 -mt-1">
<button
onclick={() => handleRemoveTrack(entry)}
class="text-gray-500 hover:text-red-400 p-1 transition-colors"
title="Remove from playlist"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z"/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
{#if showDeleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={() => showDeleteConfirm = false}
onkeydown={(e) => { if (e.key === "Escape") showDeleteConfirm = false; }}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm mx-4 space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<h3 class="text-lg font-semibold text-white">Delete Playlist?</h3>
<p class="text-gray-300">
Are you sure you want to delete "{playlist.name}"? This action cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => showDeleteConfirm = false}
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={handleDelete}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</div>
{/if}
</div>

View File

@ -8,6 +8,7 @@
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import DownloadButton from "./DownloadButton.svelte"; import DownloadButton from "./DownloadButton.svelte";
import Portal from "$lib/components/Portal.svelte"; import Portal from "$lib/components/Portal.svelte";
import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte";
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition"; import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
@ -41,6 +42,7 @@
let isPlayingTrack = $state<string | null>(null); let isPlayingTrack = $state<string | null>(null);
let openMenuId = $state<string | null>(null); let openMenuId = $state<string | null>(null);
let menuPosition = $state<MenuPosition | null>(null); let menuPosition = $state<MenuPosition | null>(null);
let addToPlaylistTrackId = $state<string | null>(null);
// Track which track is currently playing (from player store) // Track which track is currently playing (from player store)
const currentlyPlayingId = $derived($currentMedia?.id ?? null); const currentlyPlayingId = $derived($currentMedia?.id ?? null);
@ -494,10 +496,31 @@
</svg> </svg>
Add to Queue Add to Queue
</button> </button>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
addToPlaylistTrackId = selectedTrack.id;
closeMenu();
}}
class="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
</svg>
Add to Playlist
</button>
</div> </div>
</Portal> </Portal>
{/if} {/if}
{/if} {/if}
<!-- Add to Playlist Modal -->
<AddToPlaylistModal
isOpen={addToPlaylistTrackId !== null}
onClose={() => addToPlaylistTrackId = null}
trackIds={addToPlaylistTrackId ? [addToPlaylistTrackId] : []}
/>
<!-- Click outside to close menu --> <!-- Click outside to close menu -->
<svelte:window onclick={closeMenu} /> <svelte:window onclick={closeMenu} />

View File

@ -34,6 +34,19 @@ vi.mock("./DownloadButton.svelte", () => ({
default: vi.fn(() => ({ $$: {}, $set: vi.fn(), $on: vi.fn(), $destroy: vi.fn() })), default: vi.fn(() => ({ $$: {}, $set: vi.fn(), $on: vi.fn(), $destroy: vi.fn() })),
})); }));
vi.mock("$lib/stores/library", () => ({
library: {
loadLibraries: vi.fn(),
loadItems: vi.fn(),
loadItem: vi.fn(),
setCurrentLibrary: vi.fn(),
},
libraries: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
libraryItems: { subscribe: vi.fn((fn: any) => { fn([]); return () => {}; }) },
currentLibrary: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
isLibraryLoading: { subscribe: vi.fn((fn: any) => { fn(false); return () => {}; }) },
}));
// Now import the modules after mocks are set up // Now import the modules after mocks are set up
import { render, fireEvent, waitFor } from "@testing-library/svelte"; import { render, fireEvent, waitFor } from "@testing-library/svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";

View File

@ -75,26 +75,7 @@
</script> </script>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- Shuffle --> <!-- Sleep Timer (above shuffle) -->
<button
onclick={(e) => {
e.stopPropagation();
onToggleShuffle?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full transition-colors {shuffle
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
title="Shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</svg>
</button>
<!-- Sleep Timer -->
{#if !$sleepTimerActive} {#if !$sleepTimerActive}
<button <button
onclick={(e) => { onclick={(e) => {
@ -117,6 +98,25 @@
<SleepTimerIndicator onClick={onSleepTimerClick} /> <SleepTimerIndicator onClick={onSleepTimerClick} />
{/if} {/if}
<!-- Shuffle -->
<button
onclick={(e) => {
e.stopPropagation();
onToggleShuffle?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full transition-colors {shuffle
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
title="Shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</svg>
</button>
<!-- Previous --> <!-- Previous -->
<button <button
onclick={(e) => { onclick={(e) => {

View File

@ -31,11 +31,9 @@
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits"; import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
import { haptics } from "$lib/utils/haptics"; import { haptics } from "$lib/utils/haptics";
import { toast } from "$lib/stores/toast"; import { toast } from "$lib/stores/toast";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import Controls from "./Controls.svelte"; import Controls from "./Controls.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte"; import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import CastButton from "$lib/components/sessions/CastButton.svelte"; import CastButton from "$lib/components/sessions/CastButton.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import VolumeControl from "./VolumeControl.svelte"; import VolumeControl from "./VolumeControl.svelte";
import CachedImage from "../common/CachedImage.svelte"; import CachedImage from "../common/CachedImage.svelte";
@ -289,14 +287,14 @@
<div <div
role="region" role="region"
class="px-4 py-3 flex items-center gap-4 touch-pan-y relative" class="px-4 py-2 touch-pan-y relative"
ontouchstart={handleTouchStart} ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove} ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd} ontouchend={handleTouchEnd}
style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}" style="transform: translateX({swipeTransform}px); transition: {isSwiping ? 'none' : 'transform 0.3s ease-out'}"
> >
<!-- Media info --> <!-- Row 1: Media info, like, cast, overflow -->
<div class="flex items-center gap-3 flex-1 min-w-0"> <div class="flex items-center gap-3">
<!-- Artwork --> <!-- Artwork -->
<div <div
class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden" class="w-12 h-12 rounded bg-gray-800 flex-shrink-0 overflow-hidden"
@ -354,146 +352,127 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
<!-- Favorite Button --> <!-- Like Button -->
{#if displayMedia} {#if displayMedia}
<div class="hidden sm:block">
<FavoriteButton <FavoriteButton
itemId={displayMedia?.id ?? ""} itemId={displayMedia?.id ?? ""}
isFavorite={displayMedia?.userData?.isFavorite ?? false} isFavorite={displayMedia?.userData?.isFavorite ?? false}
size="sm" size="sm"
/> />
</div>
{/if}
<!-- Cast Button (visible on all screen sizes) -->
<CastButton size="sm" />
<!-- Sleep Timer Button (always visible on larger screens) -->
{#if !$sleepTimerActive}
<button
onclick={(e) => {
e.stopPropagation();
onSleepTimerClick?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full hover:bg-white/10 transition-colors hidden sm:block"
title="Sleep timer"
aria-label="Sleep timer"
>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
{/if}
<!-- Sleep Timer Indicator (shows when active) -->
<SleepTimerIndicator onClick={onSleepTimerClick} />
<!-- Volume Control (Linux only) -->
<div class="hidden sm:block">
<VolumeControl size="sm" />
</div>
<!-- Time -->
<div class="text-xs text-gray-400 hidden sm:block">
{formatTime(displayPosition)} / {formatTime(displayDuration)}
</div>
<!-- Controls -->
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
{onSleepTimerClick}
/>
<!-- Overflow Menu Button -->
<div class="relative">
<button
onclick={() => {
showOverflowMenu = !showOverflowMenu;
haptics.tap();
}}
class="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="More options"
>
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
<!-- Overflow Menu Dropdown -->
{#if showOverflowMenu}
<div
class="absolute bottom-full right-0 mb-2 w-56 bg-[var(--color-surface)] border border-gray-700 rounded-lg shadow-2xl overflow-hidden z-[70]"
role="menu"
>
<button
onclick={handleViewQueue}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
View Queue
</button>
{#if displayMedia?.albumId}
<button
onclick={handleGoToAlbum}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
Go to Album
</button>
{/if}
{#if displayMedia?.artistItems?.length}
<button
onclick={handleGoToArtist}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
Go to Artist
</button>
{/if}
<button
onclick={handleAddToPlaylist}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add to Playlist
</button>
<button
onclick={handleShare}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
Share
</button>
</div>
{/if} {/if}
<!-- Cast Button -->
<CastButton size="sm" />
<!-- Overflow Menu Button -->
<div class="relative">
<button
onclick={() => {
showOverflowMenu = !showOverflowMenu;
haptics.tap();
}}
class="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="More options"
>
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
<!-- Overflow Menu Dropdown -->
{#if showOverflowMenu}
<div
class="absolute bottom-full right-0 mb-2 w-56 bg-[var(--color-surface)] border border-gray-700 rounded-lg shadow-2xl overflow-hidden z-[70]"
role="menu"
>
<button
onclick={handleViewQueue}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
View Queue
</button>
{#if displayMedia?.albumId}
<button
onclick={handleGoToAlbum}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
Go to Album
</button>
{/if}
{#if displayMedia?.artistItems?.length}
<button
onclick={handleGoToArtist}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
Go to Artist
</button>
{/if}
<button
onclick={handleAddToPlaylist}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add to Playlist
</button>
<button
onclick={handleShare}
class="w-full px-4 py-3 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
Share
</button>
</div>
{/if}
</div>
</div>
<!-- Row 2: Playback controls (play/pause centered) -->
<div class="flex items-center mt-1">
<div class="flex-1"></div>
<Controls
isPlaying={displayIsPlaying}
{hasPrevious}
{hasNext}
{shuffle}
{repeat}
onPlayPause={handlePlayPause}
onPrevious={handlePrevious}
onNext={handleNext}
onToggleShuffle={handleToggleShuffle}
onCycleRepeat={handleCycleRepeat}
{onSleepTimerClick}
/>
<div class="flex-1 flex items-center justify-end gap-2">
<!-- Volume Control (desktop only) -->
<div class="hidden sm:block">
<VolumeControl size="sm" />
</div>
<!-- Time (desktop only) -->
<div class="text-xs text-gray-400 hidden sm:block">
{formatTime(displayPosition)} / {formatTime(displayDuration)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,13 +17,27 @@
let { isOpen = false, onClose, mediaType }: Props = $props(); let { isOpen = false, onClose, mediaType }: Props = $props();
const timePickerItems = [ const timePickerItems = [
// 5 min increments to 30
{ value: 5, label: "5 min" },
{ value: 10, label: "10 min" },
{ value: 15, label: "15 min" }, { value: 15, label: "15 min" },
{ value: 20, label: "20 min" },
{ value: 25, label: "25 min" },
{ value: 30, label: "30 min" }, { value: 30, label: "30 min" },
// 15 min increments to 2 hrs
{ value: 45, label: "45 min" }, { value: 45, label: "45 min" },
{ value: 60, label: "60 min" }, { value: 60, label: "1 hr" },
{ value: 75, label: "1 hr 15 min" },
{ value: 90, label: "1 hr 30 min" },
{ value: 105, label: "1 hr 45 min" },
{ value: 120, label: "2 hr" },
// 1 hr increments after
{ value: 180, label: "3 hr" },
{ value: 240, label: "4 hr" },
{ value: 300, label: "5 hr" },
]; ];
let selectedMinutes = $state(30); let selectedMinutes = $state(15);
const episodePresets = [1, 2, 3]; const episodePresets = [1, 2, 3];

View File

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { volume, isMuted } from "$lib/stores/player"; import { volume, isMuted, mergedVolume } from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession, sessions } from "$lib/stores/sessions";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
interface Props { interface Props {
@ -13,22 +15,32 @@
const isAndroid = platform() === "android"; const isAndroid = platform() === "android";
let showSlider = $state(false); let showSlider = $state(false);
let sliderValue = $state($volume); let sliderValue = $state($mergedVolume);
// Sync slider with store value // Sync slider with merged volume (handles both local and remote)
$effect(() => { $effect(() => {
sliderValue = $volume; sliderValue = $mergedVolume;
}); });
async function handleVolumeChange(e: Event) { async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
const newVolume = parseFloat(target.value); const newVolume = parseFloat(target.value);
sliderValue = newVolume; sliderValue = newVolume;
await invoke("player_set_volume", { volume: newVolume });
if ($isRemoteMode && $selectedSession) {
// Remote mode: send volume as 0-100 integer to remote session
await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100));
} else {
await invoke("player_set_volume", { volume: newVolume });
}
} }
async function toggleMute() { async function toggleMute() {
await invoke("player_toggle_mute"); if ($isRemoteMode && $selectedSession) {
await sessions.sendToggleMute($selectedSession.id);
} else {
await invoke("player_toggle_mute");
}
} }
function toggleSlider() { function toggleSlider() {

View File

@ -0,0 +1,163 @@
<script lang="ts">
import { onMount } from "svelte";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { libraries } from "$lib/stores/library";
import { toast } from "$lib/stores/toast";
import CreatePlaylistModal from "./CreatePlaylistModal.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
isOpen?: boolean;
onClose?: () => void;
trackIds: string[];
}
let { isOpen = false, onClose, trackIds }: Props = $props();
let playlists = $state<MediaItem[]>([]);
let loading = $state(true);
let adding = $state<string | null>(null);
let showCreateModal = $state(false);
$effect(() => {
if (isOpen) loadPlaylists();
});
async function loadPlaylists() {
loading = true;
try {
const repo = auth.getRepository();
// Find music library for playlist browsing
const musicLib = $libraries.find(lib => lib.collectionType === "music");
if (musicLib) {
const result = await repo.getItems(musicLib.id, { includeItemTypes: ["Playlist"], limit: 100 });
playlists = result.items;
} else {
// Try searching for playlists without a parent
const result = await repo.search("", { includeItemTypes: ["Playlist"], limit: 100 });
playlists = result.items;
}
} catch (e) {
console.error("[AddToPlaylist] Failed to load playlists:", e);
toast.error("Failed to load playlists");
} finally {
loading = false;
}
}
async function handleAddToPlaylist(playlist: MediaItem) {
adding = playlist.id;
try {
const repo = auth.getRepository();
await repo.addToPlaylist(playlist.id, trackIds);
toast.success(`Added to "${playlist.name}"`);
onClose?.();
} catch (e) {
console.error("[AddToPlaylist] Failed to add:", e);
toast.error("Failed to add to playlist");
} finally {
adding = null;
}
}
function handleNewPlaylist() {
showCreateModal = true;
}
function handleCreateModalClose() {
showCreateModal = false;
onClose?.();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm w-full mx-4 space-y-4 max-h-[70vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<h3 class="text-lg font-semibold text-white">Add to Playlist</h3>
<!-- New Playlist button -->
<button
onclick={handleNewPlaylist}
class="w-full flex items-center gap-3 p-3 bg-[var(--color-background)] hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors"
>
<div class="w-10 h-10 bg-[var(--color-jellyfin)] rounded flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</div>
<span class="text-white font-medium">New Playlist</span>
</button>
<!-- Playlist list -->
<div class="overflow-y-auto flex-1 space-y-1">
{#if loading}
{#each Array(3) as _}
<div class="animate-pulse flex items-center gap-3 p-3">
<div class="w-10 h-10 bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-700 rounded w-1/2"></div>
</div>
{/each}
{:else if playlists.length === 0}
<p class="text-gray-400 text-center py-4">No playlists yet</p>
{:else}
{#each playlists as playlist (playlist.id)}
<button
onclick={() => handleAddToPlaylist(playlist)}
disabled={adding === playlist.id}
class="w-full flex items-center gap-3 p-3 hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors disabled:opacity-50"
>
<div class="w-10 h-10 flex-shrink-0 rounded overflow-hidden">
{#if playlist.primaryImageTag}
<CachedImage
itemId={playlist.id}
imageType="Primary"
tag={playlist.primaryImageTag}
maxWidth={80}
alt={playlist.name}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full bg-gray-700 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/>
</svg>
</div>
{/if}
</div>
<span class="text-white text-left truncate">
{adding === playlist.id ? "Adding..." : playlist.name}
</span>
</button>
{/each}
{/if}
</div>
<button
onclick={onClose}
class="w-full px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors text-center"
>
Cancel
</button>
</div>
</div>
{/if}
<CreatePlaylistModal
isOpen={showCreateModal}
onClose={handleCreateModalClose}
initialItemIds={trackIds}
/>

View File

@ -0,0 +1,92 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
interface Props {
isOpen?: boolean;
onClose?: () => void;
initialItemIds?: string[];
}
let { isOpen = false, onClose, initialItemIds = [] }: Props = $props();
let name = $state("");
let creating = $state(false);
async function handleCreate() {
const trimmed = name.trim();
if (!trimmed) return;
creating = true;
try {
const repo = auth.getRepository();
const result = await repo.createPlaylist(trimmed, initialItemIds.length > 0 ? initialItemIds : undefined);
toast.success(`Playlist "${trimmed}" created`);
name = "";
onClose?.();
goto(`/library/${result.id}`);
} catch (e) {
console.error("[CreatePlaylist] Failed:", e);
toast.error("Failed to create playlist");
} finally {
creating = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && name.trim()) handleCreate();
if (e.key === "Escape") onClose?.();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={(e) => { if (e.key === "Escape") onClose?.(); }}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm w-full mx-4 space-y-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="presentation"
>
<h3 class="text-lg font-semibold text-white">Create Playlist</h3>
<input
type="text"
bind:value={name}
onkeydown={handleKeydown}
placeholder="Playlist name"
class="w-full px-4 py-2 bg-[var(--color-background)] border border-gray-600 rounded-lg text-white placeholder-gray-500 outline-none focus:border-[var(--color-jellyfin)] transition-colors"
/>
{#if initialItemIds.length > 0}
<p class="text-sm text-gray-400">
{initialItemIds.length} track{initialItemIds.length !== 1 ? "s" : ""} will be added.
</p>
{/if}
<div class="flex gap-3 justify-end">
<button
onclick={onClose}
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={handleCreate}
disabled={!name.trim() || creating}
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</div>
{/if}

View File

@ -4,27 +4,27 @@
* Handles user interactions with the next episode popup. * Handles user interactions with the next episode popup.
* Backend manages countdown logic and autoplay decisions. * Backend manages countdown logic and autoplay decisions.
* *
* Navigation uses goto() directly to load the next episode.
* The player page's $effect detects the URL param change and
* calls loadAndPlay for the new episode.
*
* TRACES: UR-023 | DR-047, DR-048 * TRACES: UR-023 | DR-047, DR-048
*/ */
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay"; import { goto } from "$app/navigation";
import { cancelAutoplayCountdown } from "$lib/api/autoplay";
import { nextEpisode } from "$lib/stores/nextEpisode"; import { nextEpisode } from "$lib/stores/nextEpisode";
import type { MediaItem } from "$lib/api/types";
/** Guard against double-navigation */
let isNavigating = false;
/** /**
* Cleanup next episode state (called on unmount/destroy) * Cleanup next episode state (called on unmount/destroy)
*/ */
export function cleanup() { export function cleanup() {
nextEpisode.reset(); nextEpisode.reset();
} isNavigating = false;
/**
* Handle episode ended event
* Backend now handles autoplay decisions via on_playback_ended()
* This function is kept for backwards compatibility but does nothing
*/
export async function handleEpisodeEnded(media: any) {
// Backend now handles this - no action needed
// The backend will emit ShowNextEpisodePopup event
} }
/** /**
@ -36,13 +36,36 @@ export async function cancelAutoPlay() {
nextEpisode.hidePopup(); nextEpisode.hidePopup();
} }
/**
* Navigate to the next episode via goto().
* Uses replaceState to prevent history buildup when auto-advancing.
*/
function navigateToEpisode(episode: MediaItem) {
if (isNavigating) {
console.warn("[NextEpisode] Already navigating, skipping duplicate navigation to", episode.id);
return;
}
isNavigating = true;
console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name);
nextEpisode.hidePopup();
goto(`/player/${episode.id}`, { replaceState: true }).finally(() => {
isNavigating = false;
});
}
/** /**
* Manually play the next episode * Manually play the next episode
* Called when user clicks "Play Now" button on next episode popup * Called when user clicks "Play Now" button on next episode popup
*
* @param nextEpisodeItem - The next episode to play
*/ */
export async function watchNextManually(nextEpisodeItem: any) { export async function watchNextManually(nextEpisodeItem: MediaItem) {
await playNextEpisode(nextEpisodeItem); await cancelAutoplayCountdown();
nextEpisode.hidePopup(); navigateToEpisode(nextEpisodeItem);
}
/**
* Auto-play the next episode when countdown reaches 0
* Called by playerEvents when countdown_tick event has remaining_seconds: 0
*/
export function autoPlayNext(nextEpisodeItem: MediaItem) {
navigateToEpisode(nextEpisodeItem);
} }

View File

@ -14,7 +14,8 @@ import { player, playbackPosition } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue"; import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode"; import { playbackMode } from "$lib/stores/playbackMode";
import { sleepTimer } from "$lib/stores/sleepTimer"; import { sleepTimer } from "$lib/stores/sleepTimer";
import { nextEpisode } from "$lib/stores/nextEpisode"; import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode";
import { autoPlayNext } from "$lib/services/nextEpisodeService";
import { preloadUpcomingTracks } from "$lib/services/preload"; import { preloadUpcomingTracks } from "$lib/services/preload";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { get } from "svelte/store"; import { get } from "svelte/store";
@ -320,8 +321,17 @@ function handleShowNextEpisodePopup(
/** /**
* Handle countdown tick event. * Handle countdown tick event.
* When countdown reaches 0, automatically trigger playback of the next episode.
*/ */
function handleCountdownTick(remainingSeconds: number): void { function handleCountdownTick(remainingSeconds: number): void {
// Update next episode store with new countdown value // Update next episode store with new countdown value
nextEpisode.updateCountdown(remainingSeconds); nextEpisode.updateCountdown(remainingSeconds);
// Auto-play when countdown reaches 0
if (remainingSeconds === 0) {
const episode = get(nextEpisodeItemStore);
if (episode) {
autoPlayNext(episode);
}
}
} }

View File

@ -28,7 +28,13 @@ export type SyncOperation =
| "unmark_favorite" | "unmark_favorite"
| "update_progress" | "update_progress"
| "report_playback_start" | "report_playback_start"
| "report_playback_stopped"; | "report_playback_stopped"
| "playlist_create"
| "playlist_delete"
| "playlist_rename"
| "playlist_add_items"
| "playlist_remove_items"
| "playlist_reorder_item";
/** /**
* Simplified sync service - handles offline mutation queueing * Simplified sync service - handles offline mutation queueing
@ -164,6 +170,32 @@ class SyncService {
return deleted; return deleted;
} }
// ===== Playlist sync operations =====
async queuePlaylistCreate(playlistId: string, name: string, itemIds: string[]): Promise<number> {
return this.queueMutation("playlist_create", playlistId, { name, itemIds });
}
async queuePlaylistDelete(playlistId: string): Promise<number> {
return this.queueMutation("playlist_delete", playlistId);
}
async queuePlaylistRename(playlistId: string, name: string): Promise<number> {
return this.queueMutation("playlist_rename", playlistId, { name });
}
async queuePlaylistAddItems(playlistId: string, itemIds: string[]): Promise<number> {
return this.queueMutation("playlist_add_items", playlistId, { itemIds });
}
async queuePlaylistRemoveItems(playlistId: string, entryIds: string[]): Promise<number> {
return this.queueMutation("playlist_remove_items", playlistId, { entryIds });
}
async queuePlaylistReorderItem(playlistId: string, itemId: string, newIndex: number): Promise<number> {
return this.queueMutation("playlist_reorder_item", playlistId, { itemId, newIndex });
}
/** /**
* Clear all sync operations for the current user (called during logout) * Clear all sync operations for the current user (called during logout)
* *

View File

@ -255,10 +255,14 @@ function createPlaybackModeStore() {
} }
/** /**
* Monitor remote session for disconnection * Monitor remote session for disconnection with grace period.
* Requires multiple consecutive misses before declaring disconnection
* to tolerate transient network hiccups.
*/ */
function initializeSessionMonitoring(): void { function initializeSessionMonitoring(): void {
// Subscribe to session changes let consecutiveMisses = 0;
const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval
selectedSession.subscribe((session) => { selectedSession.subscribe((session) => {
const currentState = get({ subscribe }); const currentState = get({ subscribe });
@ -266,14 +270,28 @@ function createPlaybackModeStore() {
// Don't interfere during an active transfer (we intentionally clear the session) // Don't interfere during an active transfer (we intentionally clear the session)
if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) { if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) {
if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) { if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) {
console.warn("[PlaybackMode] Remote session lost or disconnected"); consecutiveMisses++;
update((s) => ({ console.warn(`[PlaybackMode] Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
...s,
mode: "idle", if (consecutiveMisses >= DISCONNECT_THRESHOLD) {
remoteSessionId: null, console.warn("[PlaybackMode] Remote session lost after sustained disconnection");
transferError: "Remote session disconnected", consecutiveMisses = 0;
})); update((s) => ({
...s,
mode: "idle",
remoteSessionId: null,
transferError: "Remote session disconnected",
}));
}
} else {
// Session is healthy, reset counter
if (consecutiveMisses > 0) {
console.log("[PlaybackMode] Remote session recovered after", consecutiveMisses, "misses");
}
consecutiveMisses = 0;
} }
} else {
consecutiveMisses = 0;
} }
}); });
} }

View File

@ -228,15 +228,20 @@ export const mergedVolume = derived(
/** /**
* Should show audio miniplayer - state machine gated * Should show audio miniplayer - state machine gated
* Only true when: * Only true when:
* 1. Player is in playing or paused state (not idle, loading, error) * 1. In remote mode with an active session playing media, OR
* 2. Current media is audio (not video: Movie or Episode) * 2. Player is in playing or paused state (not idle, loading, error)
* AND current media is audio (not video: Movie or Episode)
*/ */
export const shouldShowAudioMiniPlayer = derived( export const shouldShowAudioMiniPlayer = derived(
[player, currentMedia], [player, currentMedia, isRemoteMode, selectedSession],
([$player, $media]) => { ([$player, $media, $isRemote, $session]) => {
const state = $player.state; // In remote mode, show if the remote session has a now-playing item
if ($isRemote && $session?.nowPlayingItem) {
return true;
}
// Only show when actively playing or paused // Local mode: only show when actively playing or paused
const state = $player.state;
if (state.kind !== "playing" && state.kind !== "paused") { if (state.kind !== "playing" && state.kind !== "paused") {
return false; return false;
} }

View File

@ -123,6 +123,65 @@ describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
expect(Object.keys(params)).toContain("repositoryHandle"); expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle"); expect(Object.keys(params)).not.toContain("repository_handle");
}); });
it("playlist_create: handle, name, itemIds (NOT item_ids)", () => {
const params = {
handle: "handle-123",
name: "My Playlist",
itemIds: ["track1", "track2"],
};
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).not.toContain("item_ids");
});
it("playlist_get_items: handle, playlistId (NOT playlist_id)", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
};
expect(Object.keys(params)).toContain("playlistId");
expect(Object.keys(params)).not.toContain("playlist_id");
});
it("playlist_add_items: playlistId, itemIds", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
itemIds: ["t1", "t2"],
};
expect(Object.keys(params)).toContain("playlistId");
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).not.toContain("playlist_id");
expect(Object.keys(params)).not.toContain("item_ids");
});
it("playlist_remove_items: playlistId, entryIds (NOT entry_ids)", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
entryIds: ["e1", "e2"],
};
expect(Object.keys(params)).toContain("entryIds");
expect(Object.keys(params)).not.toContain("entry_ids");
});
it("playlist_move_item: playlistId, itemId, newIndex (NOT new_index)", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
itemId: "track1",
newIndex: 2,
};
expect(Object.keys(params)).toContain("newIndex");
expect(Object.keys(params)).toContain("itemId");
expect(Object.keys(params)).not.toContain("new_index");
expect(Object.keys(params)).not.toContain("item_id");
});
}); });
describe("Nested struct fields also use camelCase (via serde rename_all)", () => { describe("Nested struct fields also use camelCase (via serde rename_all)", () => {

View File

@ -19,6 +19,7 @@
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte"; import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte"; import RelatedItemsSection from "$lib/components/library/RelatedItemsSection.svelte";
import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte"; import ArtistDetailView from "$lib/components/library/ArtistDetailView.svelte";
import PlaylistDetailView from "$lib/components/library/PlaylistDetailView.svelte";
import CrewLinks from "$lib/components/library/CrewLinks.svelte"; import CrewLinks from "$lib/components/library/CrewLinks.svelte";
import GenreTags from "$lib/components/library/GenreTags.svelte"; import GenreTags from "$lib/components/library/GenreTags.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@ -545,6 +546,9 @@
{:else if item.type === "MusicArtist"} {:else if item.type === "MusicArtist"}
<!-- Enhanced artist detail view with discography --> <!-- Enhanced artist detail view with discography -->
<ArtistDetailView artist={item} /> <ArtistDetailView artist={item} />
{:else if item.type === "Playlist"}
<!-- Playlist detail view with track management -->
<PlaylistDetailView playlist={item} />
{:else} {:else}
<!-- Other content in grid view --> <!-- Other content in grid view -->
<LibraryGrid <LibraryGrid

View File

@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte"; import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import CreatePlaylistModal from "$lib/components/playlist/CreatePlaylistModal.svelte";
/** /**
* Playlist browser * Playlist browser
* @req: UR-007 - Navigate media in library * @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries * @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens * @req: DR-007 - Library browsing screens
* @req: UR-014 - Make and edit playlists
*/ */
let showCreateModal = $state(false);
const config = { const config = {
itemType: "Playlist" as const, itemType: "Playlist" as const,
title: "Playlists", title: "Playlists",
@ -20,4 +24,22 @@
}; };
</script> </script>
<GenericMediaListPage {config} /> <div class="relative">
<!-- Floating create button -->
<button
onclick={() => showCreateModal = true}
class="fixed bottom-20 right-4 z-40 w-14 h-14 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-full shadow-lg flex items-center justify-center transition-colors"
aria-label="Create playlist"
>
<svg class="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</button>
<GenericMediaListPage {config} />
</div>
<CreatePlaylistModal
isOpen={showCreateModal}
onClose={() => showCreateModal = false}
/>

View File

@ -18,7 +18,7 @@
reportPlaybackProgress, reportPlaybackProgress,
reportPlaybackStopped, reportPlaybackStopped,
} from "$lib/services/playbackReporting"; } from "$lib/services/playbackReporting";
import { cleanup as cleanupNextEpisode, handleEpisodeEnded } from "$lib/services/nextEpisodeService"; import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
const itemId = $derived($page.params.id); const itemId = $derived($page.params.id);
const queueParam = $derived($page.url.searchParams.get("queue")); const queueParam = $derived($page.url.searchParams.get("queue"));
@ -62,25 +62,12 @@
let pollInterval: ReturnType<typeof setInterval> | null = null; let pollInterval: ReturnType<typeof setInterval> | null = null;
let loadedItemId: string | null = null; let loadedItemId: string | null = null;
// Handle next episode navigation event from popup
function handlePlayNextEpisode(event: Event) {
const customEvent = event as CustomEvent<{ episode: MediaItem }>;
const episode = customEvent.detail.episode;
if (episode) {
goto(`/player/${episode.id}`);
}
}
onMount(() => { onMount(() => {
// Start position polling (only for audio via MPV backend) // Start position polling (only for audio via MPV backend)
pollInterval = setInterval(updateStatus, 1000); pollInterval = setInterval(updateStatus, 1000);
// Listen for next episode navigation events
window.addEventListener("playNextEpisode", handlePlayNextEpisode);
return () => { return () => {
if (pollInterval) clearInterval(pollInterval); if (pollInterval) clearInterval(pollInterval);
window.removeEventListener("playNextEpisode", handlePlayNextEpisode);
}; };
}); });
@ -92,6 +79,7 @@
$effect(() => { $effect(() => {
const id = itemId; const id = itemId;
if (id && id !== loadedItemId) { if (id && id !== loadedItemId) {
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ")");
loadAndPlay(id); loadAndPlay(id);
} }
}); });
@ -526,10 +514,20 @@
async function handleVideoEnded() { async function handleVideoEnded() {
// Call backend to handle autoplay decision (works on both Android and Linux) // Call backend to handle autoplay decision (works on both Android and Linux)
// Pass the item ID and repository handle so the backend can look up the item
// and check for next episodes. HTML5 video plays independently of the Rust
// backend queue, so the backend needs these to know what just finished.
const mediaId = currentMedia?.id ?? null;
console.log("[AutoPlay] Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
try { try {
await invoke("player_on_playback_ended"); const repo = auth.getRepository();
const repoHandle = repo.getHandle();
await invoke("player_on_playback_ended", {
itemId: mediaId,
repositoryHandle: repoHandle,
});
} catch (e) { } catch (e) {
console.error("[VideoPlayer] Failed to handle playback ended:", e); console.error("[AutoPlay] Failed to handle playback ended:", e);
} }
} }