Files
jellytau/architecture/02-svelte-frontend.html
T

1015 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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/desktop-native-video.html"><strong aria-hidden="true">21.</strong> Desktop Native Video</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">22.</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">23.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">24.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../native-player-verification.html"><strong aria-hidden="true">25.</strong> Native Player Verification</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">26.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">27.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">28.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">29.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">30.</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&lt;br/&gt;- user&lt;br/&gt;- serverUrl&lt;br/&gt;- token&lt;br/&gt;- isLoading"]
end
subgraph playerStore["player.ts"]
PlayerStoreState["PlayerState&lt;br/&gt;- kind&lt;br/&gt;- media&lt;br/&gt;- position&lt;br/&gt;- duration"]
end
subgraph queueStore["queue.ts"]
QueueState["QueueState&lt;br/&gt;- items&lt;br/&gt;- index&lt;br/&gt;- shuffle&lt;br/&gt;- repeat"]
end
subgraph libraryStore["library.ts"]
LibraryState["LibraryState&lt;br/&gt;- libraries&lt;br/&gt;- items&lt;br/&gt;- loading"]
end
subgraph Derived["Derived Stores"]
DerivedList["isAuthenticated, currentUser&lt;br/&gt;isPlaying, isPaused, currentMedia&lt;br/&gt;hasNext, hasPrevious, isShuffle&lt;br/&gt;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&lt;br/&gt;(Landing page with category cards)"]
Tracks["Tracks&lt;br/&gt;(List view only)"]
Artists["Artists&lt;br/&gt;(Grid view)"]
Albums["Albums&lt;br/&gt;(Grid view)"]
Playlists["Playlists&lt;br/&gt;(Grid view)"]
Genres["Genres&lt;br/&gt;(Genre browser)"]
Music --&gt; Tracks
Music --&gt; Artists
Music --&gt; Albums
Music --&gt; Playlists
Music --&gt; 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">&lt;TrackList
tracks={filteredTracks}
loading={loading}
showArtist={true}
showAlbum={true}
/&gt;
</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&lt;br/&gt;(Tauri Commands)
participant Jellyfin as Jellyfin Server
VideoPlayer-&gt;&gt;PlaybackService: reportPlaybackProgress(itemId, position)
par Local Storage (always works)
PlaybackService-&gt;&gt;LocalDB: invoke("storage_update_playback_progress")
LocalDB--&gt;&gt;PlaybackService: Ok (pending_sync = true)
and Server Sync (if online)
PlaybackService-&gt;&gt;Jellyfin: POST /Sessions/Playing/Progress
Jellyfin--&gt;&gt;PlaybackService: Ok
PlaybackService-&gt;&gt;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 (&gt;30 seconds watched and &lt;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 {
&lt;&lt;trait&gt;&gt;
+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 &lt;|.. OnlineRepository
MediaRepository &lt;|.. OfflineRepository
MediaRepository &lt;|.. HybridRepository
HybridRepository --&gt; OnlineRepository
HybridRepository --&gt; 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) -&gt; String (UUID)
// All operations use handle for identification
repository_get_libraries(handle: String) -&gt; Vec&lt;Library&gt;
repository_get_items(handle: String, ...) -&gt; 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&lt;Mutex&lt;HashMap&lt;String, Arc&lt;HybridRepository&gt;&gt;&gt;&gt;</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&lt;T&gt;("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&lt;Mutex&lt;PlayerController&gt;&gt;,
jellyfin_client: Arc&lt;JellyfinClient&gt;,
}
<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-&gt;&gt;Manager: transfer_to_remote(session_id)
Manager-&gt;&gt;Player: Extract queue items
Manager-&gt;&gt;Manager: Get Jellyfin IDs from queue
Manager-&gt;&gt;Jellyfin: POST /Sessions/{id}/Playing
Note over Jellyfin: Start playback with queue
Manager-&gt;&gt;Jellyfin: POST /Sessions/{id}/Playing/Seek
Note over Jellyfin: Seek to current position
Manager-&gt;&gt;Player: Stop local playback
Manager-&gt;&gt;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> -&gt; Returns current PlaybackMode</li>
<li><code>playback_mode_transfer_to_remote(session_id)</code> -&gt; Async transfer</li>
<li><code>playback_mode_transfer_to_local(item_id, position_ticks)</code> -&gt; Async transfer back</li>
<li><code>playback_mode_is_transferring()</code> -&gt; Check transfer state</li>
<li><code>playback_mode_set(mode)</code> -&gt; 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 over <code>rusqlite</code>. <code>RusqliteService</code> owns the
database: writes run as jobs on one writer thread, reads on a pool of read-only
WAL connections, so a read never waits for a write. Callers only see the
trait (<code>execute</code>, <code>insert</code>, <code>execute_detached</code>, <code>query_one</code> / <code>query_optional</code> /
<code>query_many</code>, <code>transaction</code>, <code>transaction_without_foreign_keys</code>) and build
queries with <code>Query</code> + <code>QueryParam</code>, which keeps values out of the SQL string.</p>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>let db_service = database.service(); // cheap clone of the shared owner
let query = Query::with_params("SELECT ...", vec![...]);
db_service.query_one(query, |row| {...}).await // runs on a reader
<span class="boring">}</span></code></pre></pre>
<p>Ownership model, invariants and the reasons for them:
<a href="08-database-design.html#connection-ownership">08-database-design.md → Connection ownership</a>.</p>
<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 --&gt; PlayerComps
LibLayout --&gt; LibDetail
MusicCategory --&gt; Tracks
MusicCategory --&gt; Artists
MusicCategory --&gt; Albums
MusicCategory --&gt; Playlists
MusicCategory --&gt; Genres
LibDetail --&gt; LibraryComps
Playlists --&gt; PlaylistComps
Playlists --&gt; PlaylistDetail
Downloads --&gt; DownloadItem
PlayerPage --&gt; PlayerComps
MiniPlayer --&gt; CastButton
CastButton --&gt; SessionModal
SleepTimerModal --&gt; ScrollPicker
PlayerComps --&gt; 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-&gt;&gt;Store: setTimeTimer(30)
Store-&gt;&gt;Rust: invoke("player_set_sleep_timer", {mode})
Rust-&gt;&gt;Rust: Set SleepTimerMode::Time { end_time }
Rust-&gt;&gt;UI: Emit SleepTimerChanged event
loop Every 1 second
Thread-&gt;&gt;Thread: update_remaining_seconds()
Thread-&gt;&gt;UI: Emit SleepTimerChanged (countdown)
alt remaining_seconds == 0
Thread-&gt;&gt;Backend: stop()
Thread-&gt;&gt;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 &gt; 0</code> and <code>count &gt;= 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()"] --&gt; CheckEpisode{"Is video&lt;br/&gt;episode?"}
CheckEpisode --&gt;|"No"| AudioFlow["Audio queue logic"]
CheckEpisode --&gt;|"Yes"| FetchNext["Fetch next episode"]
FetchNext --&gt; IncrementCount["increment_autoplay_count()"]
IncrementCount --&gt; CheckLimit{"max_episodes &gt; 0&lt;br/&gt;AND count &gt;= max?"}
CheckLimit --&gt;|"No"| ShowPopup["ShowNextEpisodePopup&lt;br/&gt;auto_advance: true"]
CheckLimit --&gt;|"Yes"| ShowPopupManual["ShowNextEpisodePopup&lt;br/&gt;auto_advance: false"]
ShowPopupManual --&gt; UserClick["User clicks 'Play Now'"]
UserClick --&gt; PlayItem["play_item() -&gt; 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 &amp;&amp; !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> -&gt; <code>PlaylistCreatedResult</code></li>
<li><code>deletePlaylist(playlistId)</code></li>
<li><code>renamePlaylist(playlistId, name)</code></li>
<li><code>getPlaylistItems(playlistId)</code> -&gt; <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>
<p><strong>The route renders in exactly one element.</strong> <code>+layout.svelte</code> switches the
wrapper's <em>classes</em> between the shell scroller and the plain clipped box that
layout-owning routes (library, settings, player) get — it must not switch
between two branches that each render <code>children</code>. The page store that decides
the mode can update a flush after the new route renders, so two branches
mounted a page under one and then remounted it under the other: every
navigation between the two kinds of route loaded the page twice (DR-295).</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/&lt;seasonId&gt;</code> redirects to its series
(DR-103). Video library routes collapse to one per library (DR-105).</p>
<p><strong>The episode list never waits for Next Up or resume.</strong> <code>resolve_series_view</code>
(<code>series_progress.rs</code>, <code>with_hints</code>) returns as soon as the episodes are in;
Next Up and resume are used if they have answered by then and dropped if not,
and <code>pick_current_episode</code> falls back to the episodes' own watch state. They
only refine which episode is current, and waiting for them held the list for
the server's 23 s although every episode was cached. Next Up is cache-first
like every other query (03-data-flow).</p>
<p><strong>One load per item, however many triggers.</strong> The detail page loads through
<code>createCoalescedLoader</code> (<code>utils/coalescedLoader.ts</code>): calls for the item
already loading share that load, and callers that know the data changed
(<code>fresh</code>: reconnect, filter change, mark watched, clear history) get exactly
one re-run after it. <code>onMount</code>, a mount-time <code>$effect</code>, the reachability
effect's first run and the double mount above used to each start a full load —
six per open, about seventy requests in flight.</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="stream-transport"><a class="header" href="#stream-transport">Stream Transport</a></h2>
<p><strong>Location</strong>: <code>src/lib/player/streamTransport.ts</code>
<strong>TRACES</strong>: UR-079 | DR-225 | UT-214</p>
<p><code>videoLoaderFor(selection, capabilities)</code> picks the loader for the webview
<code>&lt;video&gt;</code> element — <code>hlsjs</code>, <code>nativeHls</code>, or <code>direct</code> — from the backend's tagged
<code>selection.transport</code>. <code>elementSrcFor</code> is its template companion: the element's
<code>src</code> is emptied only when hls.js is driving it.</p>
<p>The split is the point. <strong>The transport is the stream's property and comes from
Rust; whether a given loader exists is the browser's, and is the only thing
decided here.</strong></p>
<blockquote>
<p>This replaced <code>currentStreamUrl.includes(".m3u8")</code>, which appeared twice in
<code>VideoPlayer.svelte</code> — once in the HLS <code>$effect</code> and once inline in the
template's <code>src</code>. Rust builds that URL and knows what it is; re-deriving it
here by substring match was a domain fact reconstructed in the presentation
layer, and it fails silently in both directions. The two tests that pin it are
the ones that failed against the old implementation: a <code>progressive</code> stream
whose URL contains <code>.m3u8</code> must <strong>not</strong> get an HLS loader, and an <code>hls</code> stream
whose URL contains no <code>.m3u8</code> must.</p>
<p>Logic lives in a plain <code>.ts</code> module rather than in the component for the usual
reason — it is testable there. Same pattern as <code>episodeStrip.ts</code>.</p>
</blockquote>
<p><code>VideoPlayer</code> holds a <code>currentSelection</code>, not a URL string; <code>currentStreamUrl</code> is
derived from it. A reload replaces the selection <strong>wholesale</strong> (the adapter's
bridge takes a <code>StreamSelection</code>, not a URL), so transport and URL can never
drift apart. The background-audio handoff states the transport it is moving to —
progressive mp3 out, HLS back — via <code>selectionAt()</code>, rather than leaving it to be
inferred.</p>
<p>The quality picker is filled from <code>selection.available</code> (DR-227): rungs the
backend marked <code>exceedsSource</code> are not drawn, because they produce the same bytes
as <code>Original</code>. Nothing is optimistically assigned when the viewer picks a rung —
what the menu shows comes from the selection the backend hands back, since a
ceiling above the source bitrate <em>is</em> the source.</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>&lt;html&gt;</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 &lt; info &lt; warn &lt; 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>