# 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 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`
