Opening Frasier on a Fairphone took ~5 s to render the episode list although every episode was cached. Three causes: - resolve_series_view waited for Next Up and resume before returning the episodes, and Next Up was server-first. The episode list now returns as soon as the episodes are in (with_hints); hints that have answered are used, late ones dropped, and the picker falls back to local watch state. Next Up is cache-first like every other query. - The page loaded itself six times per open: onMount plus a mount-time $effect, the reachability effect's first run posing as a reconnect, and a double mount. All triggers now share one coalesced load per item (createCoalescedLoader); refresh triggers get one re-run after it. - The root layout rendered the route in two branches that each rendered children; the page store deciding between them updates a flush late, so navigating Search -> library page mounted the page twice. One element now renders the route and only its classes change. On the device: one load per open, seasons from cache in 14 ms, episodes and the Resume button up in under a second (was ~5 s).
873 lines
36 KiB
Markdown
873 lines
36 KiB
Markdown
# 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:**
|
||
|
||
Ordinal content (where position carries meaning) is always a list. Everything
|
||
else honours the user's persisted grid/list preference — see
|
||
[ux-flows.md §5A.2](../ux-flows.md).
|
||
|
||
| Content Type | View Mode | Toggle Visible | Component Used |
|
||
|--------------|-----------|----------------|----------------|
|
||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||
| Artists | User preference | Yes | `LibraryGrid` |
|
||
| Albums | User preference | Yes | `LibraryGrid` |
|
||
| Playlists | User preference | Yes | `LibraryGrid` |
|
||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||
|
||
**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 view mode:**
|
||
|
||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||
prop controls whether the toggle buttons appear in the page header; the grid
|
||
itself always follows the stored preference.
|
||
|
||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||
preference. No caller ever passed it, so it was removed — pages that were
|
||
documented as "forced grid" have in practice always honoured the toggle.
|
||
|
||
## 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
|
||
-connectivity: Option~Arc~ConnectivityMonitor~~
|
||
+new()
|
||
+with_connectivity()
|
||
-report_outcome()
|
||
}
|
||
|
||
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~
|
||
+new()
|
||
-parallel_race()
|
||
-cache_with_timeout()
|
||
}
|
||
|
||
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
|
||
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
|
||
|
||
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 over `rusqlite`. `RusqliteService` owns the
|
||
database: writes run as jobs on one writer thread, reads on a pool of read-only
|
||
WAL connections, so a read never waits for a write. Callers only see the
|
||
trait (`execute`, `insert`, `execute_detached`, `query_one` / `query_optional` /
|
||
`query_many`, `transaction`, `transaction_without_foreign_keys`) and build
|
||
queries with `Query` + `QueryParam`, which keeps values out of the SQL string.
|
||
|
||
```rust
|
||
let db_service = database.service(); // cheap clone of the shared owner
|
||
let query = Query::with_params("SELECT ...", vec![...]);
|
||
db_service.query_one(query, |row| {...}).await // runs on a reader
|
||
```
|
||
|
||
Ownership model, invariants and the reasons for them:
|
||
[08-database-design.md → Connection ownership](08-database-design.md#connection-ownership).
|
||
|
||
## 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
|
||
|
||
> ⚠️ **Autoplay is season-bounded.** `player/mod.rs:fetch_next_episode_for_item`
|
||
> does not cross a season boundary, so autoplay stops at the end of a season even
|
||
> though the "More Episodes" strip runs past it. Fixing it should reuse
|
||
> `repository_get_series_episodes`, but it touches the playback state machine and
|
||
> the Android JNI advance path (see the `AutoplayDecision` deadlock note in
|
||
> [CLAUDE.md](../../CLAUDE.md)) — its own change, not a drive-by.
|
||
|
||
|
||
**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`
|
||
|
||
## App Shell and Chrome
|
||
|
||
**Location**: `src/lib/utils/layoutShell.ts` (pure rules),
|
||
`src/lib/components/AppHeader.svelte`,
|
||
`src/lib/components/account/AccountMenu.svelte`, `BottomUi.svelte`
|
||
**TRACES**: UR-054 | DR-075, DR-076, DR-077
|
||
|
||
Account actions used to be reachable **only from `/library/*`** — the header
|
||
that hosted them belonged to the library layout, the bottom nav offered Home /
|
||
Search / Library, and the desktop username was inert text. From `/`, `/search`
|
||
or `/downloads` there was no route to Settings or Sign out at all. The header is
|
||
now shared and rendered from the root layout.
|
||
|
||
### Visibility rules
|
||
|
||
All four rules are pure functions in `layoutShell.ts`, so the contract is
|
||
unit-testable rather than a scattering of `$derived` booleans that drift per
|
||
route and platform (which is what they were):
|
||
|
||
| Function | Rule |
|
||
|----------|------|
|
||
| `showBottomNav` | Every authenticated route except `/player/*` and `/login` |
|
||
| `showGlobalMiniPlayer` | Everything except `/player/*`, `/login`, `/settings`. **Not** gated on platform or `/library` — the root owns the mini player everywhere, so the library route must never render a second one |
|
||
| `routeOwnsLayout` | `/library`, `/player/`, `/login` render their own full-height flex column; everything else renders into the root scroller |
|
||
| `showGlobalHeader` | Authenticated, not a layout-owning route, not `/settings` (the user is already there) |
|
||
|
||
### The structural fix worth not undoing
|
||
|
||
The "last row hidden behind the nav" bug is solved **structurally, not by
|
||
measurement**: the bottom UI is an in-flow flex child *below* the scroller
|
||
(`BottomUi.svelte`), so the scroller is physically bounded above it and cannot
|
||
render behind it. There is no measurement and no reserved padding. If you
|
||
restructure the shell, preserve the scroll containment — reintroducing padding
|
||
math reintroduces the bug.
|
||
|
||
**The route renders in exactly one element.** `+layout.svelte` switches the
|
||
wrapper's *classes* between the shell scroller and the plain clipped box that
|
||
layout-owning routes (library, settings, player) get — it must not switch
|
||
between two branches that each render `children`. The page store that decides
|
||
the mode can update a flush after the new route renders, so two branches
|
||
mounted a page under one and then remounted it under the other: every
|
||
navigation between the two kinds of route loaded the page twice (DR-295).
|
||
|
||
### AccountMenu
|
||
|
||
One component for both breakpoints, anchored to the username/avatar (a real
|
||
button with `aria-expanded`, not a bare three-dot icon). Fixed item order:
|
||
identity block (user + server) → Downloads, Settings, Display → divider → Sign
|
||
out, destructive and last. Dismissal is backdrop click, `Escape`, and focus
|
||
return to the trigger.
|
||
|
||
The identity block falls back to the bare host of the server URL when the server
|
||
has no human-readable name, so it always shows *something* server-identifying.
|
||
|
||
Settings' Display section and the library page-header toggle are two views onto
|
||
the **same** persisted `viewMode` store (`jellytau-view-mode`) — no second state,
|
||
no migration, and they stay in sync for free.
|
||
|
||
## Library Mosaic
|
||
|
||
**Location**: `src/lib/components/library/libraryMosaic.ts` (pure),
|
||
`MosaicGrid.svelte`, `MosaicTile.svelte`
|
||
**TRACES**: UR-075, UR-067 | DR-174, DR-175
|
||
|
||
The library overview and the home "Your Libraries" strip are a **mosaic**, not a
|
||
grid: rows share one height and each tile is as wide as its own artwork is, so a
|
||
square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row
|
||
at their own proportions instead of all three being cropped into whichever box a
|
||
grid picked.
|
||
|
||
`libraryMosaic.ts` is deliberately pure — it takes the libraries and returns the
|
||
tiles to draw, so ordering and de-duplication are unit-testable rather than
|
||
buried in markup. Tiles start at an *assumed* aspect (square, 16:9) and a
|
||
measured image overrides it in `MosaicGrid`.
|
||
|
||
Note what this file does **not** decide: which favourites category a library
|
||
belongs to. That is Jellyfin vocabulary and arrives on the library itself as
|
||
`favoritesScope`, from `SearchScope::for_collection_type` in Rust (see
|
||
[01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)).
|
||
The frontend only decides what to *call* it and where to put it.
|
||
|
||
## Series and Episode Navigation
|
||
|
||
**Location**: `src/lib/components/library/` — `SeasonSection.svelte`,
|
||
`EpisodeFocusView.svelte`, `episodeStrip.ts` (pure)
|
||
**TRACES**: UR-062 … UR-064 | DR-101 … DR-107
|
||
|
||
Opening a series lands the viewer where they actually are in it. **"Where is this
|
||
viewer in this series" is resolved in Rust** (DR-101), not by the page: the
|
||
series detail page asks the repository and anchors on the answer — the current
|
||
season expanded, the current episode highlighted and scrolled into view, and a
|
||
hero button labelled `Resume S2E4` / `Play S1E1`.
|
||
|
||
A season is not a destination: `/library/<seasonId>` redirects to its series
|
||
(DR-103). Video library routes collapse to one per library (DR-105).
|
||
|
||
**The episode list never waits for Next Up or resume.** `resolve_series_view`
|
||
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
|
||
Next Up and resume are used if they have answered by then and dropped if not,
|
||
and `pick_current_episode` falls back to the episodes' own watch state. They
|
||
only refine which episode is current, and waiting for them held the list for
|
||
the server's 2–3 s although every episode was cached. Next Up is cache-first
|
||
like every other query (03-data-flow).
|
||
|
||
**One load per item, however many triggers.** The detail page loads through
|
||
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
|
||
already loading share that load, and callers that know the data changed
|
||
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
|
||
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
|
||
effect's first run and the double mount above used to each start a full load —
|
||
six per open, about seventy requests in flight.
|
||
|
||
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
||
from the component because it had three distinct bugs that markup made
|
||
untestable: the strip collapsing to just the current episode while real siblings
|
||
existed, number-less episodes all matching as "current" (`undefined ===
|
||
undefined`), and the window dead-ending at a season boundary instead of running
|
||
past it. It matches by id first and only falls back to season+episode number when
|
||
both numbers are known on both sides.
|
||
|
||
## Downloaded Browse
|
||
|
||
**Location**: `src/lib/services/downloadedCatalog.ts`,
|
||
`src/lib/components/downloads/DownloadedBrowse.svelte`
|
||
**TRACES**: UR-055, UR-056 | DR-081 … DR-085
|
||
|
||
`/downloads` is two views: **Downloaded** (the default) — the library filtered to
|
||
what is on the device, reusing the same grids, cards and detail pages as online
|
||
browsing — and **Transfers**, the in-flight progress rows demoted to a secondary
|
||
tab.
|
||
|
||
`downloadedCatalog` reads the **offline-only** browse path on the repository,
|
||
never the hybrid merge. That is the point: an empty result means "nothing
|
||
downloaded here", never "server unreachable", so the view is authoritative
|
||
regardless of connectivity. It also owns disk usage — a per-item/container byte
|
||
map plus the device total, aggregated by the backend from `downloads.file_size`
|
||
(DR-085).
|
||
|
||
## Safe-area Insets
|
||
|
||
**Location**: `src/app.css`, `WindowInsetsBridge.kt`
|
||
**TRACES**: UR-066 | DR-112, IR-031
|
||
|
||
The Android WebView does not reliably report system-bar insets through
|
||
`env(safe-area-inset-*)`. Native `WindowInsets` (`systemBars() |
|
||
displayCutout()`) are therefore pushed in as CSS custom properties, and every
|
||
edge takes the larger of the two sources:
|
||
|
||
```css
|
||
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
|
||
```
|
||
|
||
Two rules keep this from going wrong: **one owner per edge** (two components both
|
||
padding the top edge double-pads it), and **no nested `h-screen`** — a full-height
|
||
child inside a full-height parent that has already consumed the inset overflows
|
||
by exactly the inset.
|
||
|
||
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
||
can safely be re-sent on resume.
|
||
|
||
## Stream Transport
|
||
|
||
**Location**: `src/lib/player/streamTransport.ts`
|
||
**TRACES**: UR-079 | DR-225 | UT-214
|
||
|
||
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
|
||
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
|
||
`selection.transport`. `elementSrcFor` is its template companion: the element's
|
||
`src` is emptied only when hls.js is driving it.
|
||
|
||
The split is the point. **The transport is the stream's property and comes from
|
||
Rust; whether a given loader exists is the browser's, and is the only thing
|
||
decided here.**
|
||
|
||
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
|
||
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
|
||
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
|
||
> here by substring match was a domain fact reconstructed in the presentation
|
||
> layer, and it fails silently in both directions. The two tests that pin it are
|
||
> the ones that failed against the old implementation: a `progressive` stream
|
||
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
|
||
> whose URL contains no `.m3u8` must.
|
||
>
|
||
> Logic lives in a plain `.ts` module rather than in the component for the usual
|
||
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
|
||
|
||
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
||
derived from it. A reload replaces the selection **wholesale** (the adapter's
|
||
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
|
||
drift apart. The background-audio handoff states the transport it is moving to —
|
||
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
|
||
inferred.
|
||
|
||
The quality picker is filled from `selection.available` (DR-227): rungs the
|
||
backend marked `exceedsSource` are not drawn, because they produce the same bytes
|
||
as `Original`. Nothing is optimistically assigned when the viewer picks a rung —
|
||
what the menu shows comes from the selection the backend hands back, since a
|
||
ceiling above the source bitrate *is* the source.
|
||
|
||
## Native Video Store
|
||
|
||
**Location**: `src/lib/stores/nativeVideo.ts`
|
||
**TRACES**: UR-003, UR-004 | DR-188
|
||
|
||
Two separate concerns live here, deliberately:
|
||
|
||
- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**.
|
||
Rust already decides *which backend this platform has* (`useHtml5Element` from
|
||
`player_play_item`); this flag only *suppresses* that decision. It never turns
|
||
native on where Rust says HTML5. An explicit stored choice wins in both
|
||
directions, so someone who opted out is not re-enabled by a default flip —
|
||
hence the `null` check rather than a bare `=== "true"`.
|
||
- `nativeVideoActive` — whether a native surface is on screen *right now*.
|
||
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
|
||
`app.css` keys off to clear the app's opaque backgrounds. It is deliberately
|
||
**not** derived from the flag: the backgrounds must come back the moment the
|
||
player unmounts.
|
||
|
||
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
|
||
for what is behind the WebView.
|
||
|
||
## Logging
|
||
|
||
**Location**: `src/lib/utils/logger.ts`
|
||
**TRACES**: DR-204
|
||
|
||
The frontend's equivalent of the Rust `log` crate: four levels
|
||
(`debug < info < warn < error`), a compile-environment default (dev → `debug`,
|
||
production → `warn`), and a runtime override that is the moral equivalent of
|
||
`RUST_LOG`. Scoped loggers carry the subsystem in the message, so a filtered
|
||
console stays usable while a player, a download worker and a store are all
|
||
talking.
|
||
|
||
Production deliberately keeps **warn and error**: this is a client talking to a
|
||
server that may or may not be there, and a silent failure is worse to support
|
||
than a noisy console. Only the chatter is suppressed.
|
||
|
||
`no-console` is an ESLint **error**, with the sink module itself the only
|
||
exception, so a raw `console.*` cannot re-appear.
|