jellytau/docs/architecture/02-svelte-frontend.md
Duncan Tourolle 09780103a7
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
2026-03-01 19:47:46 +01:00

24 KiB

Svelte Frontend Architecture

Store Structure

Location: src/lib/stores/

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:

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:

<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
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/

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):

    // 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:

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)):

    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:

#[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:

// 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

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):

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:

#[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:

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:

// 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
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:

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