1015 lines
64 KiB
HTML
1015 lines
64 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/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<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 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 --> 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>
|
||
<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/<seasonId></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 2–3 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><video></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><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>
|