Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
This commit is contained in:
@@ -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`
|
||||
Reference in New Issue
Block a user