Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
This commit is contained in:
parent
3a9c126dfe
commit
09780103a7
22
README.md
22
README.md
@ -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
571
docs/architecture/01-rust-backend.md
Normal file
571
docs/architecture/01-rust-backend.md
Normal 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` | - | `()` |
|
||||||
644
docs/architecture/02-svelte-frontend.md
Normal file
644
docs/architecture/02-svelte-frontend.md
Normal 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`
|
||||||
154
docs/architecture/03-data-flow.md
Normal file
154
docs/architecture/03-data-flow.md
Normal 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
|
||||||
132
docs/architecture/04-type-sync-and-threading.md
Normal file
132
docs/architecture/04-type-sync-and-threading.md
Normal 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 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
485
docs/architecture/05-platform-backends.md
Normal file
485
docs/architecture/05-platform-backends.md
Normal 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())
|
||||||
|
}
|
||||||
|
```
|
||||||
287
docs/architecture/06-downloads-and-offline.md
Normal file
287
docs/architecture/06-downloads-and-offline.md
Normal 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');
|
||||||
|
```
|
||||||
92
docs/architecture/07-connectivity.md
Normal file
92
docs/architecture/07-connectivity.md
Normal 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
|
||||||
614
docs/architecture/08-database-design.md
Normal file
614
docs/architecture/08-database-design.md
Normal 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
|
||||||
|
```
|
||||||
69
docs/architecture/09-security.md
Normal file
69
docs/architecture/09-security.md
Normal 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
|
||||||
@ -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::*;
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
115
src-tauri/src/commands/playlist.rs
Normal file
115
src-tauri/src/commands/playlist.rs
Normal 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))
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(¤t).await {
|
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||||
if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? {
|
let repo = self.repository.lock().unwrap().clone();
|
||||||
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.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 ¤t_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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)]
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"));
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
312
src/lib/components/library/PlaylistDetailView.svelte
Normal file
312
src/lib/components/library/PlaylistDetailView.svelte
Normal 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}
|
||||||
|
· {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>
|
||||||
@ -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} />
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
163
src/lib/components/playlist/AddToPlaylistModal.svelte
Normal file
163
src/lib/components/playlist/AddToPlaylistModal.svelte
Normal 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}
|
||||||
|
/>
|
||||||
92
src/lib/components/playlist/CreatePlaylistModal.svelte
Normal file
92
src/lib/components/playlist/CreatePlaylistModal.svelte
Normal 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}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)", () => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user