998 lines
61 KiB
HTML
998 lines
61 KiB
HTML
<!DOCTYPE HTML>
|
|
<html lang="en" class="navy" dir="ltr">
|
|
<head>
|
|
<!-- Book generated using mdBook -->
|
|
<meta charset="UTF-8">
|
|
<title>Svelte Frontend - JellyTau Documentation</title>
|
|
|
|
|
|
<!-- Custom HTML head -->
|
|
|
|
<meta name="description" content="Requirements, traceability, and architecture for the JellyTau Jellyfin client.">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="theme-color" content="#ffffff">
|
|
|
|
<link rel="icon" href="../favicon.svg">
|
|
<link rel="shortcut icon" href="../favicon.png">
|
|
<link rel="stylesheet" href="../css/variables.css">
|
|
<link rel="stylesheet" href="../css/general.css">
|
|
<link rel="stylesheet" href="../css/chrome.css">
|
|
<link rel="stylesheet" href="../css/print.css" media="print">
|
|
|
|
<!-- Fonts -->
|
|
<link rel="stylesheet" href="../FontAwesome/css/font-awesome.css">
|
|
<link rel="stylesheet" href="../fonts/fonts.css">
|
|
|
|
<!-- Highlight.js Stylesheets -->
|
|
<link rel="stylesheet" href="../highlight.css">
|
|
<link rel="stylesheet" href="../tomorrow-night.css">
|
|
<link rel="stylesheet" href="../ayu-highlight.css">
|
|
|
|
<!-- Custom theme stylesheets -->
|
|
|
|
</head>
|
|
<body class="sidebar-visible no-js">
|
|
<div id="body-container">
|
|
<!-- Provide site root to javascript -->
|
|
<script>
|
|
var path_to_root = "../";
|
|
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "navy" : "navy";
|
|
</script>
|
|
|
|
<!-- Work around some values being stored in localStorage wrapped in quotes -->
|
|
<script>
|
|
try {
|
|
var theme = localStorage.getItem('mdbook-theme');
|
|
var sidebar = localStorage.getItem('mdbook-sidebar');
|
|
|
|
if (theme.startsWith('"') && theme.endsWith('"')) {
|
|
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
|
|
}
|
|
|
|
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
|
|
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
|
|
}
|
|
} catch (e) { }
|
|
</script>
|
|
|
|
<!-- Set the theme before any content is loaded, prevents flash -->
|
|
<script>
|
|
var theme;
|
|
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
|
|
if (theme === null || theme === undefined) { theme = default_theme; }
|
|
var html = document.querySelector('html');
|
|
html.classList.remove('navy')
|
|
html.classList.add(theme);
|
|
var body = document.querySelector('body');
|
|
body.classList.remove('no-js')
|
|
body.classList.add('js');
|
|
</script>
|
|
|
|
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
|
|
|
|
<!-- Hide / unhide sidebar before it is displayed -->
|
|
<script>
|
|
var body = document.querySelector('body');
|
|
var sidebar = null;
|
|
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
|
|
if (document.body.clientWidth >= 1080) {
|
|
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
|
|
sidebar = sidebar || 'visible';
|
|
} else {
|
|
sidebar = 'hidden';
|
|
}
|
|
sidebar_toggle.checked = sidebar === 'visible';
|
|
body.classList.remove('sidebar-visible');
|
|
body.classList.add("sidebar-" + sidebar);
|
|
</script>
|
|
|
|
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
|
|
<div class="sidebar-scrollbox">
|
|
<ol class="chapter"><li class="chapter-item expanded affix "><a href="../index.html">Introduction</a></li><li class="chapter-item expanded affix "><li class="part-title">Requirements & Traceability</li><li class="chapter-item expanded "><a href="../requirements.html"><strong aria-hidden="true">1.</strong> Requirements Specification</a></li><li class="chapter-item expanded "><a href="../traceability.html"><strong aria-hidden="true">2.</strong> Traceability Matrix</a></li><li class="chapter-item expanded "><a href="../traceability-ci.html"><strong aria-hidden="true">3.</strong> Traceability CI</a></li><li class="chapter-item expanded "><a href="../traces-quick-ref.html"><strong aria-hidden="true">4.</strong> Traces Quick Reference</a></li><li class="chapter-item expanded affix "><li class="part-title">Architecture</li><li class="chapter-item expanded "><a href="../architecture/index.html"><strong aria-hidden="true">5.</strong> Overview</a><a class="toggle"><div>❱</div></a></li><li><ol class="section"><li class="chapter-item "><a href="../architecture/01-rust-backend.html"><strong aria-hidden="true">5.1.</strong> Rust Backend</a></li><li class="chapter-item expanded "><a href="../architecture/02-svelte-frontend.html" class="active"><strong aria-hidden="true">5.2.</strong> Svelte Frontend</a></li><li class="chapter-item "><a href="../architecture/03-data-flow.html"><strong aria-hidden="true">5.3.</strong> Data Flow</a></li><li class="chapter-item "><a href="../architecture/04-type-sync-and-threading.html"><strong aria-hidden="true">5.4.</strong> Type Sync & Threading</a></li><li class="chapter-item "><a href="../architecture/05-platform-backends.html"><strong aria-hidden="true">5.5.</strong> Platform Backends</a></li><li class="chapter-item "><a href="../architecture/06-downloads-and-offline.html"><strong aria-hidden="true">5.6.</strong> Downloads & Offline</a></li><li class="chapter-item "><a href="../architecture/07-connectivity.html"><strong aria-hidden="true">5.7.</strong> Connectivity</a></li><li class="chapter-item "><a href="../architecture/08-database-design.html"><strong aria-hidden="true">5.8.</strong> Database Design</a></li><li class="chapter-item "><a href="../architecture/09-security.html"><strong aria-hidden="true">5.9.</strong> Security</a></li></ol></li><li class="chapter-item expanded "><li class="part-title">UX</li><li class="chapter-item expanded "><a href="../ux-flows.html"><strong aria-hidden="true">6.</strong> UX Flows</a></li><li class="chapter-item expanded affix "><li class="part-title">Specs — Pending Work</li><li class="chapter-item expanded "><a href="../specs/index.html"><strong aria-hidden="true">7.</strong> Specs Index</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-TEMPLATE.html"><strong aria-hidden="true">8.</strong> Spec Template</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-REVIEW-CHECKLIST.html"><strong aria-hidden="true">9.</strong> Spec Review Checklist</a></li><li class="chapter-item expanded "><a href="../specs/playback-backend-unification.html"><strong aria-hidden="true">10.</strong> Playback Backend Unification</a></li><li class="chapter-item expanded "><a href="../specs/linux-native-video-spike.html"><strong aria-hidden="true">11.</strong> Linux Native Video Spike</a></li><li class="chapter-item expanded "><a href="../specs/backend-owned-stream-selection.html"><strong aria-hidden="true">12.</strong> Backend-Owned Stream Selection</a></li><li class="chapter-item expanded "><a href="../specs/player-facade-enforcement.html"><strong aria-hidden="true">13.</strong> Player Facade Enforcement</a></li><li class="chapter-item expanded "><a href="../specs/windows-native-audio-backend.html"><strong aria-hidden="true">14.</strong> Windows Native Audio Backend</a></li><li class="chapter-item expanded "><a href="../specs/libmpv2-migration.html"><strong aria-hidden="true">15.</strong> libmpv2 Migration</a></li><li class="chapter-item expanded "><a href="../specs/read-through-media-cache.html"><strong aria-hidden="true">16.</strong> Read-Through Media Cache</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search.html"><strong aria-hidden="true">17.</strong> Scoped Search</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary.html"><strong aria-hidden="true">18.</strong> Scoped Search Boundary</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary-implementation.html"><strong aria-hidden="true">19.</strong> Scoped Search Boundary — Implementation</a></li><li class="chapter-item expanded "><a href="../specs/frontend-domain-model.html"><strong aria-hidden="true">20.</strong> Frontend Domain Model</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">21.</strong> Build Provenance</a></li><li class="chapter-item expanded affix "><li class="part-title">Build & Release</li><li class="chapter-item expanded "><a href="../build/build-release.html"><strong aria-hidden="true">22.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">23.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">24.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">25.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">26.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">27.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">28.</strong> Builder Image</a></li><li class="chapter-item expanded affix "><li class="spacer"></li><li class="chapter-item expanded affix "><a href="../api-redirect.html">Rust API Reference (rustdoc)</a></li></ol>
|
|
</div>
|
|
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
|
|
<div class="sidebar-resize-indicator"></div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Track and set sidebar scroll position -->
|
|
<script>
|
|
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
|
|
sidebarScrollbox.addEventListener('click', function(e) {
|
|
if (e.target.tagName === 'A') {
|
|
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
|
|
}
|
|
}, { passive: true });
|
|
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
|
|
sessionStorage.removeItem('sidebar-scroll');
|
|
if (sidebarScrollTop) {
|
|
// preserve sidebar scroll position when navigating via links within sidebar
|
|
sidebarScrollbox.scrollTop = sidebarScrollTop;
|
|
} else {
|
|
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
|
|
var activeSection = document.querySelector('#sidebar .active');
|
|
if (activeSection) {
|
|
activeSection.scrollIntoView({ block: 'center' });
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div id="page-wrapper" class="page-wrapper">
|
|
|
|
<div class="page">
|
|
<div id="menu-bar-hover-placeholder"></div>
|
|
<div id="menu-bar" class="menu-bar sticky">
|
|
<div class="left-buttons">
|
|
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
|
|
<i class="fa fa-bars"></i>
|
|
</label>
|
|
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
|
|
<i class="fa fa-paint-brush"></i>
|
|
</button>
|
|
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
|
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
|
|
</ul>
|
|
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
|
|
<i class="fa fa-search"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<h1 class="menu-title">JellyTau Documentation</h1>
|
|
|
|
<div class="right-buttons">
|
|
<a href="../print.html" title="Print this book" aria-label="Print this book">
|
|
<i id="print-button" class="fa fa-print"></i>
|
|
</a>
|
|
<a href="https://gitea.tourolle.paris/dtourolle/jellytau" title="Git repository" aria-label="Git repository">
|
|
<i id="git-repository-button" class="fa fa-github"></i>
|
|
</a>
|
|
<a href="https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/../docs/architecture/02-svelte-frontend.md" title="Suggest an edit" aria-label="Suggest an edit">
|
|
<i id="git-edit-button" class="fa fa-edit"></i>
|
|
</a>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div id="search-wrapper" class="hidden">
|
|
<form id="searchbar-outer" class="searchbar-outer">
|
|
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
|
|
</form>
|
|
<div id="searchresults-outer" class="searchresults-outer hidden">
|
|
<div id="searchresults-header" class="searchresults-header"></div>
|
|
<ul id="searchresults">
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
|
|
<script>
|
|
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
|
|
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
|
|
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
|
|
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
|
|
});
|
|
</script>
|
|
|
|
<div id="content" class="content">
|
|
<main>
|
|
<h1 id="svelte-frontend-architecture"><a class="header" href="#svelte-frontend-architecture">Svelte Frontend Architecture</a></h1>
|
|
<h2 id="store-structure"><a class="header" href="#store-structure">Store Structure</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/stores/</code></p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<h2 id="music-library-architecture"><a class="header" href="#music-library-architecture">Music Library Architecture</a></h2>
|
|
<p><strong>Category-Based Navigation:</strong></p>
|
|
<p>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.</p>
|
|
<p><strong>Route Structure:</strong></p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<p><strong>View Enforcement:</strong></p>
|
|
<p>Ordinal content (where position carries meaning) is always a list. Everything
|
|
else honours the user's persisted grid/list preference — see
|
|
<a href="../ux-flows.html">ux-flows.md §5A.2</a>.</p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Content Type</th><th>View Mode</th><th>Toggle Visible</th><th>Component Used</th></tr></thead><tbody>
|
|
<tr><td>Tracks</td><td>List (forced — ordinal)</td><td>No</td><td><code>TrackList</code></td></tr>
|
|
<tr><td>Artists</td><td>User preference</td><td>Yes</td><td><code>LibraryGrid</code></td></tr>
|
|
<tr><td>Albums</td><td>User preference</td><td>Yes</td><td><code>LibraryGrid</code></td></tr>
|
|
<tr><td>Playlists</td><td>User preference</td><td>Yes</td><td><code>LibraryGrid</code></td></tr>
|
|
<tr><td>Genres</td><td>User preference (both levels)</td><td>Yes</td><td><code>LibraryGrid</code></td></tr>
|
|
<tr><td>Album Detail Tracks</td><td>List (forced — ordinal)</td><td>No</td><td><code>TrackList</code></td></tr>
|
|
<tr><td>Season Episodes</td><td>List (forced — ordinal)</td><td>No</td><td><code>SeasonSection</code></td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><strong>TrackList Component:</strong></p>
|
|
<p>The <code>TrackList</code> component (<code>src/lib/components/library/TrackList.svelte</code>) is a dedicated component for displaying songs in list format:</p>
|
|
<ul>
|
|
<li><strong>No Thumbnails</strong>: Track numbers only (transform to play button on hover)</li>
|
|
<li><strong>Desktop Layout</strong>: Table with columns: #, Title, Artist, Album, Duration</li>
|
|
<li><strong>Mobile Layout</strong>: Compact rows with track number and metadata</li>
|
|
<li><strong>Configurable Columns</strong>: <code>showArtist</code> and <code>showAlbum</code> props control column visibility</li>
|
|
<li><strong>Click Behavior</strong>: Clicking a track plays it and queues all filtered tracks</li>
|
|
</ul>
|
|
<p><strong>Example Usage:</strong></p>
|
|
<pre><code class="language-svelte"><TrackList
|
|
tracks={filteredTracks}
|
|
loading={loading}
|
|
showArtist={true}
|
|
showAlbum={true}
|
|
/>
|
|
</code></pre>
|
|
<p><strong>LibraryGrid view mode:</strong></p>
|
|
<p><code>LibraryGrid</code> reads the global <code>viewMode</code> store (persisted to <code>localStorage</code>)
|
|
and renders <code>LibraryListView</code> or the card grid accordingly. The <code>showViewToggle</code>
|
|
prop controls whether the toggle buttons appear in the page header; the grid
|
|
itself always follows the stored preference.</p>
|
|
<p>A <code>forceGrid</code> 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.</p>
|
|
<h2 id="playback-reporting-service"><a class="header" href="#playback-reporting-service">Playback Reporting Service</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/services/playbackReporting.ts</code></p>
|
|
<p>The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:</p>
|
|
<ul>
|
|
<li>Offline "Continue Watching" functionality</li>
|
|
<li>Sync queue for when network is unavailable</li>
|
|
<li>Consistent progress across app restarts</li>
|
|
</ul>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<p><strong>Service Functions:</strong></p>
|
|
<ul>
|
|
<li><code>reportPlaybackStart(itemId, positionSeconds)</code> - Called when playback begins</li>
|
|
<li><code>reportPlaybackProgress(itemId, positionSeconds, isPaused)</code> - Called periodically (every 10s)</li>
|
|
<li><code>reportPlaybackStopped(itemId, positionSeconds)</code> - Called when player closes or video ends</li>
|
|
</ul>
|
|
<p><strong>Tauri Commands:</strong></p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Command</th><th>Description</th></tr></thead><tbody>
|
|
<tr><td><code>storage_update_playback_progress</code></td><td>Update position in local DB (marks <code>pending_sync = true</code>)</td></tr>
|
|
<tr><td><code>storage_mark_played</code></td><td>Mark item as played, increment play count</td></tr>
|
|
<tr><td><code>storage_get_playback_progress</code></td><td>Get stored progress for an item</td></tr>
|
|
<tr><td><code>storage_mark_synced</code></td><td>Clear <code>pending_sync</code> flag after successful server sync</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><strong>Database Schema Notes:</strong></p>
|
|
<ul>
|
|
<li>The <code>user_data</code> table stores playback progress using Jellyfin IDs directly (as TEXT)</li>
|
|
<li>Playback progress can be tracked even when the full item metadata hasn't been downloaded yet</li>
|
|
</ul>
|
|
<p><strong>Resume Playback Feature:</strong></p>
|
|
<ul>
|
|
<li>When loading media for playback, the app checks local database for saved progress</li>
|
|
<li>If progress exists (>30 seconds watched and <90% complete), shows resume dialog</li>
|
|
<li>User can choose to "Resume" from saved position or "Start from Beginning"</li>
|
|
<li>For video: Uses <code>startTimeSeconds</code> parameter in stream URL to begin transcoding from resume point</li>
|
|
<li>For audio: Seeks to resume position after loading via MPV backend</li>
|
|
<li>Implemented in <code>src/routes/player/[id]/+page.svelte</code></li>
|
|
</ul>
|
|
<h2 id="repository-architecture-rust-based"><a class="header" href="#repository-architecture-rust-based">Repository Architecture (Rust-Based)</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/repository/</code></p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<p><strong>Key Implementation Details:</strong></p>
|
|
<ol>
|
|
<li>
|
|
<p><strong>Cache-First Racing Strategy</strong> (<code>hybrid.rs</code>):</p>
|
|
<ul>
|
|
<li>Runs cache (SQLite) and server (HTTP) queries in parallel</li>
|
|
<li>Cache has 100ms timeout</li>
|
|
<li>Returns cache result if it has meaningful content</li>
|
|
<li>Falls back to server result otherwise</li>
|
|
<li>Background cache updates planned</li>
|
|
<li><strong>Connectivity feedback</strong>: <code>OnlineRepository</code> reports the outcome of every server request to the <code>ConnectivityMonitor</code> (classified via <code>RepoError</code>). This is the source of truth for the offline/online banner — see <a href="07-connectivity.html">07-connectivity.md</a>. The frontend <code>connectivity</code> store is a pure reflection of the resulting events; <code>navigator.onLine</code> is only an advisory hint that triggers an immediate recheck.</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<p><strong>Handle-Based Resource Management</strong> (<code>repository.rs</code> commands):</p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>// 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)
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<ul>
|
|
<li>Enables multiple concurrent repository instances</li>
|
|
<li>Thread-safe with <code>Arc<Mutex<HashMap<String, Arc<HybridRepository>>>></code></li>
|
|
<li>No global state conflicts</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<p><strong>Frontend API Layer</strong> (<code>src/lib/api/repository-client.ts</code>):</p>
|
|
<ul>
|
|
<li>Thin TypeScript wrapper over Rust commands</li>
|
|
<li>Maintains handle throughout session</li>
|
|
<li>All methods: <code>invoke<T>("repository_operation", { handle, ...args })</code></li>
|
|
<li>~100 lines (down from 1061 lines)</li>
|
|
</ul>
|
|
</li>
|
|
</ol>
|
|
<h2 id="playback-mode-system"><a class="header" href="#playback-mode-system">Playback Mode System</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/playback_mode/mod.rs</code></p>
|
|
<p>The playback mode system manages transitions between local device playback and remote Jellyfin session control:</p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>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>,
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Key Operations:</strong></p>
|
|
<ol>
|
|
<li>
|
|
<p><strong>Transfer to Remote</strong> (<code>transfer_to_remote(session_id)</code>):</p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
</li>
|
|
<li>
|
|
<p><strong>Transfer to Local</strong> (<code>transfer_to_local(item_id, position_ticks)</code>):</p>
|
|
<ul>
|
|
<li>Stops remote session playback</li>
|
|
<li>Prepares local player to resume</li>
|
|
<li>Sets mode to Local</li>
|
|
</ul>
|
|
</li>
|
|
</ol>
|
|
<p><strong>Tauri Commands</strong> (<code>playback_mode.rs</code>):</p>
|
|
<ul>
|
|
<li><code>playback_mode_get_current()</code> -> Returns current PlaybackMode</li>
|
|
<li><code>playback_mode_transfer_to_remote(session_id)</code> -> Async transfer</li>
|
|
<li><code>playback_mode_transfer_to_local(item_id, position_ticks)</code> -> Async transfer back</li>
|
|
<li><code>playback_mode_is_transferring()</code> -> Check transfer state</li>
|
|
<li><code>playback_mode_set(mode)</code> -> Direct mode setting</li>
|
|
</ul>
|
|
<p><strong>Frontend Store</strong> (<code>src/lib/stores/playbackMode.ts</code>):</p>
|
|
<ul>
|
|
<li>Thin wrapper calling Rust commands</li>
|
|
<li>Maintains UI state (isTransferring, transferError)</li>
|
|
<li>Listens to mode change events from Rust</li>
|
|
</ul>
|
|
<h2 id="database-service-abstraction"><a class="header" href="#database-service-abstraction">Database Service Abstraction</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/storage/db_service.rs</code></p>
|
|
<p>Async database interface wrapping synchronous <code>rusqlite</code> to prevent blocking the Tokio runtime:</p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>#[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
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Key Benefits:</strong></p>
|
|
<ul>
|
|
<li><strong>No Freezing</strong>: All blocking DB ops run in thread pool via <code>spawn_blocking</code></li>
|
|
<li><strong>Type Safety</strong>: <code>QueryParam</code> enum prevents SQL injection</li>
|
|
<li><strong>Future Proof</strong>: Easy to swap to native async DB (tokio-rusqlite)</li>
|
|
<li><strong>Testable</strong>: Can mock DatabaseService for tests</li>
|
|
</ul>
|
|
<p><strong>Usage Pattern:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>// 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
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<h2 id="component-hierarchy"><a class="header" href="#component-hierarchy">Component Hierarchy</a></h2>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<h2 id="miniplayer-behavior"><a class="header" href="#miniplayer-behavior">MiniPlayer Behavior</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/components/player/MiniPlayer.svelte</code></p>
|
|
<p>The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.</p>
|
|
<p><strong>Touch Gesture Handling:</strong></p>
|
|
<p>The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):</p>
|
|
<pre><code class="language-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;
|
|
}
|
|
</code></pre>
|
|
<p><strong>Key Design Decision</strong>: <code>touchEndX</code>/<code>touchEndY</code> must be initialized to the start position in <code>handleTouchStart</code>. Without this, a pure tap (no <code>touchmove</code> 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.</p>
|
|
<p><strong>Skip Button State:</strong></p>
|
|
<p>The MiniPlayer's next/previous buttons are enabled based on <code>appState.hasNext</code>/<code>hasPrevious</code>, which are updated by <code>playerEvents.ts</code> calling <code>invoke("player_get_queue")</code> on every <code>StateChanged</code> event from the backend.</p>
|
|
<h2 id="sleep-timer-architecture"><a class="header" href="#sleep-timer-architecture">Sleep Timer Architecture</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/sleep_timer.rs</code>, <code>src-tauri/src/player/mod.rs</code></p>
|
|
<p><strong>TRACES</strong>: UR-026 | DR-029</p>
|
|
<p>The sleep timer supports three modes for stopping playback:</p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>#[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
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Timer Modes:</strong></p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Mode</th><th>Trigger</th><th>How It Stops</th></tr></thead><tbody>
|
|
<tr><td>Time</td><td>User selects 15/30/45/60 min via roller UI</td><td>Background timer thread stops backend when <code>remaining_seconds == 0</code>; also checked at track boundaries in <code>on_playback_ended()</code></td></tr>
|
|
<tr><td>EndOfTrack</td><td>User clicks "End of current track"</td><td>Checked in <code>on_playback_ended()</code>, returns <code>AutoplayDecision::Stop</code></td></tr>
|
|
<tr><td>Episodes</td><td>User selects 1-10 episodes</td><td><code>decrement_episode()</code> in <code>on_playback_ended()</code>, stops when counter reaches 0</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><strong>Time-Based Timer Flow:</strong></p>
|
|
<pre><code class="language-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
|
|
</code></pre>
|
|
<p><strong>Frontend Components:</strong></p>
|
|
<ul>
|
|
<li><strong>ScrollPicker</strong> (<code>src/lib/components/common/ScrollPicker.svelte</code>): Reusable scroll-wheel picker using CSS <code>scroll-snap-type: y mandatory</code>. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.</li>
|
|
<li><strong>SleepTimerModal</strong> (<code>src/lib/components/player/SleepTimerModal.svelte</code>): 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 <code>mediaType</code> prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).</li>
|
|
<li><strong>SleepTimerIndicator</strong> (<code>src/lib/components/player/SleepTimerIndicator.svelte</code>): Compact indicator showing active timer status with countdown.</li>
|
|
<li><strong>Sleep buttons</strong>: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.</li>
|
|
</ul>
|
|
<p><strong>Key Design Decisions:</strong></p>
|
|
<ol>
|
|
<li><strong>All logic in Rust</strong>: Frontend only displays state and invokes commands</li>
|
|
<li><strong>Background timer thread</strong>: Handles time-based countdown independently of track boundaries</li>
|
|
<li><strong>Dual stop mechanism for Time mode</strong>: Timer thread stops mid-track; <code>on_playback_ended()</code> catches edge case at track boundary</li>
|
|
<li><strong>Event-driven UI updates</strong>: Timer thread emits <code>SleepTimerChanged</code> every second for countdown display</li>
|
|
</ol>
|
|
<h2 id="auto-play-episode-limit"><a class="header" href="#auto-play-episode-limit">Auto-Play Episode Limit</a></h2>
|
|
<blockquote>
|
|
<p>⚠️ <strong>Autoplay is season-bounded.</strong> <code>player/mod.rs:fetch_next_episode_for_item</code>
|
|
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
|
|
<code>repository_get_series_episodes</code>, but it touches the playback state machine and
|
|
the Android JNI advance path (see the <code>AutoplayDecision</code> deadlock note in
|
|
<a href="../../CLAUDE.html">CLAUDE.md</a>) — its own change, not a drive-by.</p>
|
|
</blockquote>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/mod.rs</code>, <code>src-tauri/src/player/autoplay.rs</code>, <code>src-tauri/src/settings.rs</code></p>
|
|
<p><strong>TRACES</strong>: UR-023 | DR-049</p>
|
|
<p>Limits how many episodes auto-play consecutively before requiring manual intervention.</p>
|
|
<p><strong>Settings:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>// 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
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Session-Based Counter:</strong></p>
|
|
<p>The <code>autoplay_episode_count</code> field in <code>PlayerController</code> tracks consecutive auto-played episodes:</p>
|
|
<ul>
|
|
<li><strong>Incremented</strong>: In <code>on_playback_ended()</code> when auto-playing next episode</li>
|
|
<li><strong>Reset</strong>: On any manual user action (<code>play_item()</code>, <code>play_queue()</code>, <code>next()</code>, <code>previous()</code>)</li>
|
|
<li><strong>Limit check</strong>: When <code>max_episodes > 0</code> and <code>count >= max_episodes</code>, the popup shows with <code>auto_advance: false</code> - user must manually click "Play Now" to continue</li>
|
|
</ul>
|
|
<pre><code class="language-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"]
|
|
</code></pre>
|
|
<p><strong>Settings Sync:</strong></p>
|
|
<p><code>VideoSettings</code> (settings page) and <code>AutoplaySettings</code> (PlayerController runtime) are synced via <code>player_set_video_settings</code>, which updates both the <code>VideoSettingsWrapper</code> state and calls <code>controller.set_autoplay_settings()</code>.</p>
|
|
<p><strong>Database</strong>: Migration 016 adds <code>autoplay_max_episodes INTEGER DEFAULT 0</code> to <code>user_player_settings</code>.</p>
|
|
<p><strong>Settings UI</strong>: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.</p>
|
|
<h2 id="player-page-navigation-guard"><a class="header" href="#player-page-navigation-guard">Player Page Navigation Guard</a></h2>
|
|
<p><strong>Location</strong>: <code>src/routes/player/[id]/+page.svelte</code></p>
|
|
<p>When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the <code>loadAndPlay</code> function checks whether the track is already playing before initiating new playback:</p>
|
|
<pre><code class="language-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;
|
|
}
|
|
</code></pre>
|
|
<p><strong>Why This Matters</strong>: 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 <code>loadAndPlay</code> function would overwrite it by calling <code>player_play_tracks</code> with just the current track.</p>
|
|
<h2 id="playlist-management-ui"><a class="header" href="#playlist-management-ui">Playlist Management UI</a></h2>
|
|
<p><strong>TRACES</strong>: UR-014 | JA-019 | JA-020</p>
|
|
<p><strong>Location</strong>: <code>src/lib/components/playlist/</code>, <code>src/lib/components/library/PlaylistDetailView.svelte</code></p>
|
|
<p>The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.</p>
|
|
<p><strong>Components:</strong></p>
|
|
<ul>
|
|
<li>
|
|
<p><strong>CreatePlaylistModal</strong> (<code>src/lib/components/playlist/CreatePlaylistModal.svelte</code>):</p>
|
|
<ul>
|
|
<li>Modal for creating new playlists with a name input</li>
|
|
<li>Accepts optional <code>initialItemIds</code> to pre-populate with tracks</li>
|
|
<li>Keyboard support: Enter to create, Escape to close</li>
|
|
<li>Navigates to new playlist detail page on creation</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<p><strong>AddToPlaylistModal</strong> (<code>src/lib/components/playlist/AddToPlaylistModal.svelte</code>):</p>
|
|
<ul>
|
|
<li>Modal listing all existing playlists to add tracks to</li>
|
|
<li>"New Playlist" button for inline creation flow</li>
|
|
<li>Shows playlist artwork via CachedImage</li>
|
|
<li>Loading state with skeleton placeholders</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<p><strong>PlaylistDetailView</strong> (<code>src/lib/components/library/PlaylistDetailView.svelte</code>):</p>
|
|
<ul>
|
|
<li>Full playlist detail page with artwork, name, track count, total duration</li>
|
|
<li>Click-to-rename with inline editing</li>
|
|
<li>Play all / shuffle play buttons</li>
|
|
<li>Delete with confirmation dialog</li>
|
|
<li>Per-track removal buttons</li>
|
|
<li>Uses <code>TrackList</code> component for track display</li>
|
|
<li>Passes <code>{ type: "playlist", playlistId, playlistName }</code> context to player</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<p><strong>Playlists Page</strong> (<code>src/routes/library/music/playlists/+page.svelte</code>):</p>
|
|
<ul>
|
|
<li>Grid view using <code>GenericMediaListPage</code></li>
|
|
<li>Floating action button (FAB) to create new playlists</li>
|
|
<li>Search by playlist name</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
<p><strong>Frontend API Methods</strong> (<code>src/lib/api/repository-client.ts</code>):</p>
|
|
<ul>
|
|
<li><code>createPlaylist(name, itemIds?)</code> -> <code>PlaylistCreatedResult</code></li>
|
|
<li><code>deletePlaylist(playlistId)</code></li>
|
|
<li><code>renamePlaylist(playlistId, name)</code></li>
|
|
<li><code>getPlaylistItems(playlistId)</code> -> <code>PlaylistEntry[]</code></li>
|
|
<li><code>addToPlaylist(playlistId, itemIds)</code></li>
|
|
<li><code>removeFromPlaylist(playlistId, entryIds)</code></li>
|
|
<li><code>movePlaylistItem(playlistId, itemId, newIndex)</code></li>
|
|
</ul>
|
|
<p><strong>Offline Sync</strong> (<code>src/lib/services/syncService.ts</code>):
|
|
All playlist mutations are queued for offline sync:</p>
|
|
<ul>
|
|
<li><code>queuePlaylistCreate</code>, <code>queuePlaylistDelete</code>, <code>queuePlaylistRename</code></li>
|
|
<li><code>queuePlaylistAddItems</code>, <code>queuePlaylistRemoveItems</code>, <code>queuePlaylistReorderItem</code></li>
|
|
</ul>
|
|
<h2 id="app-shell-and-chrome"><a class="header" href="#app-shell-and-chrome">App Shell and Chrome</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/utils/layoutShell.ts</code> (pure rules),
|
|
<code>src/lib/components/AppHeader.svelte</code>,
|
|
<code>src/lib/components/account/AccountMenu.svelte</code>, <code>BottomUi.svelte</code>
|
|
<strong>TRACES</strong>: UR-054 | DR-075, DR-076, DR-077</p>
|
|
<p>Account actions used to be reachable <strong>only from <code>/library/*</code></strong> — 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 <code>/</code>, <code>/search</code>
|
|
or <code>/downloads</code> there was no route to Settings or Sign out at all. The header is
|
|
now shared and rendered from the root layout.</p>
|
|
<h3 id="visibility-rules"><a class="header" href="#visibility-rules">Visibility rules</a></h3>
|
|
<p>All four rules are pure functions in <code>layoutShell.ts</code>, so the contract is
|
|
unit-testable rather than a scattering of <code>$derived</code> booleans that drift per
|
|
route and platform (which is what they were):</p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Function</th><th>Rule</th></tr></thead><tbody>
|
|
<tr><td><code>showBottomNav</code></td><td>Every authenticated route except <code>/player/*</code> and <code>/login</code></td></tr>
|
|
<tr><td><code>showGlobalMiniPlayer</code></td><td>Everything except <code>/player/*</code>, <code>/login</code>, <code>/settings</code>. <strong>Not</strong> gated on platform or <code>/library</code> — the root owns the mini player everywhere, so the library route must never render a second one</td></tr>
|
|
<tr><td><code>routeOwnsLayout</code></td><td><code>/library</code>, <code>/player/</code>, <code>/login</code> render their own full-height flex column; everything else renders into the root scroller</td></tr>
|
|
<tr><td><code>showGlobalHeader</code></td><td>Authenticated, not a layout-owning route, not <code>/settings</code> (the user is already there)</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<h3 id="the-structural-fix-worth-not-undoing"><a class="header" href="#the-structural-fix-worth-not-undoing">The structural fix worth not undoing</a></h3>
|
|
<p>The "last row hidden behind the nav" bug is solved <strong>structurally, not by
|
|
measurement</strong>: the bottom UI is an in-flow flex child <em>below</em> the scroller
|
|
(<code>BottomUi.svelte</code>), 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.</p>
|
|
<h3 id="accountmenu"><a class="header" href="#accountmenu">AccountMenu</a></h3>
|
|
<p>One component for both breakpoints, anchored to the username/avatar (a real
|
|
button with <code>aria-expanded</code>, 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, <code>Escape</code>, and focus
|
|
return to the trigger.</p>
|
|
<p>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 <em>something</em> server-identifying.</p>
|
|
<p>Settings' Display section and the library page-header toggle are two views onto
|
|
the <strong>same</strong> persisted <code>viewMode</code> store (<code>jellytau-view-mode</code>) — no second state,
|
|
no migration, and they stay in sync for free.</p>
|
|
<h2 id="library-mosaic"><a class="header" href="#library-mosaic">Library Mosaic</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/components/library/libraryMosaic.ts</code> (pure),
|
|
<code>MosaicGrid.svelte</code>, <code>MosaicTile.svelte</code>
|
|
<strong>TRACES</strong>: UR-075, UR-067 | DR-174, DR-175</p>
|
|
<p>The library overview and the home "Your Libraries" strip are a <strong>mosaic</strong>, 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.</p>
|
|
<p><code>libraryMosaic.ts</code> 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 <em>assumed</em> aspect (square, 16:9) and a
|
|
measured image overrides it in <code>MosaicGrid</code>.</p>
|
|
<p>Note what this file does <strong>not</strong> decide: which favourites category a library
|
|
belongs to. That is Jellyfin vocabulary and arrives on the library itself as
|
|
<code>favoritesScope</code>, from <code>SearchScope::for_collection_type</code> in Rust (see
|
|
<a href="01-rust-backend.html#search-scope-and-the-taxonomy-boundary">01-rust-backend.md</a>).
|
|
The frontend only decides what to <em>call</em> it and where to put it.</p>
|
|
<h2 id="series-and-episode-navigation"><a class="header" href="#series-and-episode-navigation">Series and Episode Navigation</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/components/library/</code> — <code>SeasonSection.svelte</code>,
|
|
<code>EpisodeFocusView.svelte</code>, <code>episodeStrip.ts</code> (pure)
|
|
<strong>TRACES</strong>: UR-062 … UR-064 | DR-101 … DR-107</p>
|
|
<p>Opening a series lands the viewer where they actually are in it. <strong>"Where is this
|
|
viewer in this series" is resolved in Rust</strong> (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 <code>Resume S2E4</code> / <code>Play S1E1</code>.</p>
|
|
<p>A season is not a destination: <code>/library/<seasonId></code> redirects to its series
|
|
(DR-103). Video library routes collapse to one per library (DR-105).</p>
|
|
<p><code>episodeStrip.ts</code> 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" (<code>undefined === undefined</code>), 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.</p>
|
|
<h2 id="downloaded-browse"><a class="header" href="#downloaded-browse">Downloaded Browse</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/services/downloadedCatalog.ts</code>,
|
|
<code>src/lib/components/downloads/DownloadedBrowse.svelte</code>
|
|
<strong>TRACES</strong>: UR-055, UR-056 | DR-081 … DR-085</p>
|
|
<p><code>/downloads</code> is two views: <strong>Downloaded</strong> (the default) — the library filtered to
|
|
what is on the device, reusing the same grids, cards and detail pages as online
|
|
browsing — and <strong>Transfers</strong>, the in-flight progress rows demoted to a secondary
|
|
tab.</p>
|
|
<p><code>downloadedCatalog</code> reads the <strong>offline-only</strong> 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 <code>downloads.file_size</code>
|
|
(DR-085).</p>
|
|
<h2 id="safe-area-insets"><a class="header" href="#safe-area-insets">Safe-area Insets</a></h2>
|
|
<p><strong>Location</strong>: <code>src/app.css</code>, <code>WindowInsetsBridge.kt</code>
|
|
<strong>TRACES</strong>: UR-066 | DR-112, IR-031</p>
|
|
<p>The Android WebView does not reliably report system-bar insets through
|
|
<code>env(safe-area-inset-*)</code>. Native <code>WindowInsets</code> (<code>systemBars() | displayCutout()</code>) are therefore pushed in as CSS custom properties, and every
|
|
edge takes the larger of the two sources:</p>
|
|
<pre><code class="language-css">--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
|
|
</code></pre>
|
|
<p>Two rules keep this from going wrong: <strong>one owner per edge</strong> (two components both
|
|
padding the top edge double-pads it), and <strong>no nested <code>h-screen</code></strong> — a full-height
|
|
child inside a full-height parent that has already consumed the inset overflows
|
|
by exactly the inset.</p>
|
|
<p>Unlike <code>addJavascriptInterface</code>, the inset push only writes CSS properties, so it
|
|
can safely be re-sent on resume.</p>
|
|
<h2 id="native-video-store"><a class="header" href="#native-video-store">Native Video Store</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/stores/nativeVideo.ts</code>
|
|
<strong>TRACES</strong>: UR-003, UR-004 | DR-188</p>
|
|
<p>Two separate concerns live here, deliberately:</p>
|
|
<ul>
|
|
<li><code>experimentalNativeVideo</code> — the user-facing opt-in flag, <strong>defaulting to on</strong>.
|
|
Rust already decides <em>which backend this platform has</em> (<code>useHtml5Element</code> from
|
|
<code>player_play_item</code>); this flag only <em>suppresses</em> 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 <code>null</code> check rather than a bare <code>=== "true"</code>.</li>
|
|
<li><code>nativeVideoActive</code> — whether a native surface is on screen <em>right now</em>.
|
|
Setting it toggles <code>data-native-video</code> on <code><html></code>, which is what the CSS in
|
|
<code>app.css</code> keys off to clear the app's opaque backgrounds. It is deliberately
|
|
<strong>not</strong> derived from the flag: the backgrounds must come back the moment the
|
|
player unmounts.</li>
|
|
</ul>
|
|
<p>See <a href="05-platform-backends.html#native-video-compositing-android">05-platform-backends.md</a>
|
|
for what is behind the WebView.</p>
|
|
<h2 id="logging"><a class="header" href="#logging">Logging</a></h2>
|
|
<p><strong>Location</strong>: <code>src/lib/utils/logger.ts</code>
|
|
<strong>TRACES</strong>: DR-204</p>
|
|
<p>The frontend's equivalent of the Rust <code>log</code> crate: four levels
|
|
(<code>debug < info < warn < error</code>), a compile-environment default (dev → <code>debug</code>,
|
|
production → <code>warn</code>), and a runtime override that is the moral equivalent of
|
|
<code>RUST_LOG</code>. 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.</p>
|
|
<p>Production deliberately keeps <strong>warn and error</strong>: 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.</p>
|
|
<p><code>no-console</code> is an ESLint <strong>error</strong>, with the sink module itself the only
|
|
exception, so a raw <code>console.*</code> cannot re-appear.</p>
|
|
|
|
</main>
|
|
|
|
<nav class="nav-wrapper" aria-label="Page navigation">
|
|
<!-- Mobile navigation buttons -->
|
|
<a rel="prev" href="../architecture/01-rust-backend.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
|
<i class="fa fa-angle-left"></i>
|
|
</a>
|
|
|
|
<a rel="next prefetch" href="../architecture/03-data-flow.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
|
<i class="fa fa-angle-right"></i>
|
|
</a>
|
|
|
|
<div style="clear: both"></div>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="nav-wide-wrapper" aria-label="Page navigation">
|
|
<a rel="prev" href="../architecture/01-rust-backend.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
|
|
<i class="fa fa-angle-left"></i>
|
|
</a>
|
|
|
|
<a rel="next prefetch" href="../architecture/03-data-flow.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
|
|
<i class="fa fa-angle-right"></i>
|
|
</a>
|
|
</nav>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
window.playground_copyable = true;
|
|
</script>
|
|
|
|
|
|
<script src="../elasticlunr.min.js"></script>
|
|
<script src="../mark.min.js"></script>
|
|
<script src="../searcher.js"></script>
|
|
|
|
<script src="../clipboard.min.js"></script>
|
|
<script src="../highlight.js"></script>
|
|
<script src="../book.js"></script>
|
|
|
|
<!-- Custom JS scripts -->
|
|
|
|
|
|
</div>
|
|
</body>
|
|
</html>
|