872 lines
53 KiB
HTML
872 lines
53 KiB
HTML
<!DOCTYPE HTML>
|
|
<html lang="en" class="navy" dir="ltr">
|
|
<head>
|
|
<!-- Book generated using mdBook -->
|
|
<meta charset="UTF-8">
|
|
<title>Rust Backend - 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 expanded "><a href="../architecture/01-rust-backend.html" class="active"><strong aria-hidden="true">5.1.</strong> Rust Backend</a></li><li class="chapter-item "><a href="../architecture/02-svelte-frontend.html"><strong aria-hidden="true">5.2.</strong> Svelte Frontend</a></li><li class="chapter-item "><a href="../architecture/03-data-flow.html"><strong aria-hidden="true">5.3.</strong> Data Flow</a></li><li class="chapter-item "><a href="../architecture/04-type-sync-and-threading.html"><strong aria-hidden="true">5.4.</strong> Type Sync & Threading</a></li><li class="chapter-item "><a href="../architecture/05-platform-backends.html"><strong aria-hidden="true">5.5.</strong> Platform Backends</a></li><li class="chapter-item "><a href="../architecture/06-downloads-and-offline.html"><strong aria-hidden="true">5.6.</strong> Downloads & Offline</a></li><li class="chapter-item "><a href="../architecture/07-connectivity.html"><strong aria-hidden="true">5.7.</strong> Connectivity</a></li><li class="chapter-item "><a href="../architecture/08-database-design.html"><strong aria-hidden="true">5.8.</strong> Database Design</a></li><li class="chapter-item "><a href="../architecture/09-security.html"><strong aria-hidden="true">5.9.</strong> Security</a></li></ol></li><li class="chapter-item expanded "><li class="part-title">UX</li><li class="chapter-item expanded "><a href="../ux-flows.html"><strong aria-hidden="true">6.</strong> UX Flows</a></li><li class="chapter-item expanded affix "><li class="part-title">Specs — Pending Work</li><li class="chapter-item expanded "><a href="../specs/index.html"><strong aria-hidden="true">7.</strong> Specs Index</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-TEMPLATE.html"><strong aria-hidden="true">8.</strong> Spec Template</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-REVIEW-CHECKLIST.html"><strong aria-hidden="true">9.</strong> Spec Review Checklist</a></li><li class="chapter-item expanded "><a href="../specs/playback-backend-unification.html"><strong aria-hidden="true">10.</strong> Playback Backend Unification</a></li><li class="chapter-item expanded "><a href="../specs/linux-native-video-spike.html"><strong aria-hidden="true">11.</strong> Linux Native Video Spike</a></li><li class="chapter-item expanded "><a href="../specs/backend-owned-stream-selection.html"><strong aria-hidden="true">12.</strong> Backend-Owned Stream Selection</a></li><li class="chapter-item expanded "><a href="../specs/player-facade-enforcement.html"><strong aria-hidden="true">13.</strong> Player Facade Enforcement</a></li><li class="chapter-item expanded "><a href="../specs/windows-native-audio-backend.html"><strong aria-hidden="true">14.</strong> Windows Native Audio Backend</a></li><li class="chapter-item expanded "><a href="../specs/libmpv2-migration.html"><strong aria-hidden="true">15.</strong> libmpv2 Migration</a></li><li class="chapter-item expanded "><a href="../specs/read-through-media-cache.html"><strong aria-hidden="true">16.</strong> Read-Through Media Cache</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search.html"><strong aria-hidden="true">17.</strong> Scoped Search</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary.html"><strong aria-hidden="true">18.</strong> Scoped Search Boundary</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary-implementation.html"><strong aria-hidden="true">19.</strong> Scoped Search Boundary — Implementation</a></li><li class="chapter-item expanded "><a href="../specs/frontend-domain-model.html"><strong aria-hidden="true">20.</strong> Frontend Domain Model</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">21.</strong> Build Provenance</a></li><li class="chapter-item expanded affix "><li class="part-title">Build & Release</li><li class="chapter-item expanded "><a href="../build/build-release.html"><strong aria-hidden="true">22.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">23.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">24.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">25.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">26.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">27.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">28.</strong> Builder Image</a></li><li class="chapter-item expanded affix "><li class="spacer"></li><li class="chapter-item expanded affix "><a href="../api-redirect.html">Rust API Reference (rustdoc)</a></li></ol>
|
|
</div>
|
|
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
|
|
<div class="sidebar-resize-indicator"></div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Track and set sidebar scroll position -->
|
|
<script>
|
|
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
|
|
sidebarScrollbox.addEventListener('click', function(e) {
|
|
if (e.target.tagName === 'A') {
|
|
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
|
|
}
|
|
}, { passive: true });
|
|
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
|
|
sessionStorage.removeItem('sidebar-scroll');
|
|
if (sidebarScrollTop) {
|
|
// preserve sidebar scroll position when navigating via links within sidebar
|
|
sidebarScrollbox.scrollTop = sidebarScrollTop;
|
|
} else {
|
|
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
|
|
var activeSection = document.querySelector('#sidebar .active');
|
|
if (activeSection) {
|
|
activeSection.scrollIntoView({ block: 'center' });
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div id="page-wrapper" class="page-wrapper">
|
|
|
|
<div class="page">
|
|
<div id="menu-bar-hover-placeholder"></div>
|
|
<div id="menu-bar" class="menu-bar sticky">
|
|
<div class="left-buttons">
|
|
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
|
|
<i class="fa fa-bars"></i>
|
|
</label>
|
|
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
|
|
<i class="fa fa-paint-brush"></i>
|
|
</button>
|
|
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
|
|
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
|
|
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
|
|
</ul>
|
|
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
|
|
<i class="fa fa-search"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<h1 class="menu-title">JellyTau Documentation</h1>
|
|
|
|
<div class="right-buttons">
|
|
<a href="../print.html" title="Print this book" aria-label="Print this book">
|
|
<i id="print-button" class="fa fa-print"></i>
|
|
</a>
|
|
<a href="https://gitea.tourolle.paris/dtourolle/jellytau" title="Git repository" aria-label="Git repository">
|
|
<i id="git-repository-button" class="fa fa-github"></i>
|
|
</a>
|
|
<a href="https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/../docs/architecture/01-rust-backend.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="rust-backend-architecture"><a class="header" href="#rust-backend-architecture">Rust Backend Architecture</a></h1>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/</code></p>
|
|
<h2 id="media-session-state-machine"><a class="header" href="#media-session-state-machine">Media Session State Machine</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/session.rs</code></p>
|
|
<p>The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.</p>
|
|
<p><strong>Architecture Note:</strong> The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.</p>
|
|
<pre><code class="language-mermaid">stateDiagram-v2
|
|
[*] --> Idle
|
|
|
|
Idle --> AudioActive : play_queue(audio)
|
|
Idle --> MovieActive : play_item(movie)
|
|
Idle --> TvShowActive : play_item(episode)
|
|
|
|
state "Audio Session" as AudioSession {
|
|
[*] --> AudioActive
|
|
AudioActive --> AudioInactive : playback_ended
|
|
AudioInactive --> AudioActive : resume/play
|
|
AudioActive --> AudioActive : next/previous
|
|
}
|
|
|
|
state "Movie Session" as MovieSession {
|
|
[*] --> MovieActive
|
|
MovieActive --> MovieInactive : playback_ended
|
|
MovieInactive --> MovieActive : resume
|
|
}
|
|
|
|
state "TV Show Session" as TvShowSession {
|
|
[*] --> TvShowActive
|
|
TvShowActive --> TvShowInactive : playback_ended
|
|
TvShowInactive --> TvShowActive : next_episode/resume
|
|
}
|
|
|
|
AudioSession --> Idle : dismiss/clear_queue
|
|
AudioSession --> MovieSession : play_item(movie)
|
|
AudioSession --> TvShowSession : play_item(episode)
|
|
|
|
MovieSession --> Idle : dismiss/playback_complete
|
|
MovieSession --> AudioSession : play_queue(audio)
|
|
|
|
TvShowSession --> Idle : dismiss/series_complete
|
|
TvShowSession --> AudioSession : play_queue(audio)
|
|
|
|
note right of Idle
|
|
No active media session
|
|
Queue may exist but not playing
|
|
No miniplayer/video player shown
|
|
end note
|
|
|
|
note right of AudioSession
|
|
SHOW: Miniplayer (always visible)
|
|
- Active: Play/pause/skip controls enabled
|
|
- Inactive: Play button to resume queue
|
|
Persists until explicit dismiss
|
|
end note
|
|
|
|
note right of MovieSession
|
|
SHOW: Full video player
|
|
- Active: Video playing/paused
|
|
- Inactive: Resume dialog
|
|
Auto-dismiss when playback ends
|
|
end note
|
|
|
|
note right of TvShowSession
|
|
SHOW: Full video player + Next Episode UI
|
|
- Active: Video playing/paused
|
|
- Inactive: Next episode prompt
|
|
Auto-dismiss when series ends
|
|
end note
|
|
</code></pre>
|
|
<p><strong>Session State Enum:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum MediaSessionType {
|
|
/// No active session - browsing library
|
|
Idle,
|
|
|
|
/// Audio playback session (music, audiobooks, podcasts)
|
|
/// Persists until explicitly dismissed
|
|
Audio {
|
|
/// Last/current track being played
|
|
last_item: Option<MediaItem>,
|
|
/// True = playing/paused, False = stopped/ended
|
|
is_active: bool,
|
|
},
|
|
|
|
/// Movie playback (single video, auto-dismiss on end)
|
|
Movie {
|
|
item: MediaItem,
|
|
is_active: bool, // true = playing/paused, false = ended
|
|
},
|
|
|
|
/// TV show playback (supports next episode auto-advance)
|
|
TvShow {
|
|
item: MediaItem,
|
|
series_id: String,
|
|
is_active: bool, // true = playing/paused, false = ended
|
|
},
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>State Transitions & Rules:</strong></p>
|
|
<div class="table-wrapper"><table><thead><tr><th>From State</th><th>Event</th><th>To State</th><th>UI Behavior</th><th>Notes</th></tr></thead><tbody>
|
|
<tr><td>Idle</td><td><code>play_queue(audio)</code></td><td>Audio (active)</td><td>Show miniplayer</td><td>Creates audio session</td></tr>
|
|
<tr><td>Idle</td><td><code>play_item(movie)</code></td><td>Movie (active)</td><td>Show video player</td><td>Creates movie session</td></tr>
|
|
<tr><td>Idle</td><td><code>play_item(episode)</code></td><td>TvShow (active)</td><td>Show video player</td><td>Creates TV session</td></tr>
|
|
<tr><td>Audio (active)</td><td><code>playback_ended</code></td><td>Audio (inactive)</td><td>Miniplayer stays visible</td><td>Queue preserved</td></tr>
|
|
<tr><td>Audio (inactive)</td><td><code>play/resume</code></td><td>Audio (active)</td><td>Miniplayer enabled</td><td>Resume from queue</td></tr>
|
|
<tr><td>Audio (active/inactive)</td><td><code>dismiss</code></td><td>Idle</td><td>Hide miniplayer</td><td>Clear session</td></tr>
|
|
<tr><td>Audio (active/inactive)</td><td><code>play_item(movie)</code></td><td>Movie (active)</td><td>Switch to video player</td><td>Replace session</td></tr>
|
|
<tr><td>Movie (active)</td><td><code>playback_ended</code></td><td>Idle</td><td>Hide video player</td><td>Auto-dismiss</td></tr>
|
|
<tr><td>Movie (active)</td><td><code>dismiss</code></td><td>Idle</td><td>Hide video player</td><td>User dismiss</td></tr>
|
|
<tr><td>TvShow (active)</td><td><code>playback_ended</code></td><td>TvShow (inactive)</td><td>Show next episode UI</td><td>Wait for user choice</td></tr>
|
|
<tr><td>TvShow (inactive)</td><td><code>next_episode</code></td><td>TvShow (active)</td><td>Play next episode</td><td>Stay in session</td></tr>
|
|
<tr><td>TvShow (inactive)</td><td><code>series_complete</code></td><td>Idle</td><td>Hide video player</td><td>No more episodes</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><strong>Key Design Decisions:</strong></p>
|
|
<ol>
|
|
<li><strong>Audio Sessions Persist</strong>: Miniplayer stays visible even when queue ends, allows easy resume</li>
|
|
<li><strong>Video Sessions Auto-Dismiss</strong>: Movies auto-close when finished (unless paused)</li>
|
|
<li><strong>Single Active Session</strong>: Playing new content type replaces current session</li>
|
|
<li><strong>Explicit Dismiss for Audio</strong>: User must click close button to clear audio session</li>
|
|
<li><strong>Session != PlayerState</strong>: Session is higher-level, PlayerState tracks playing/paused/seeking</li>
|
|
</ol>
|
|
<p><strong>Edge Cases Handled:</strong></p>
|
|
<ul>
|
|
<li>Album finishes: Session goes inactive, miniplayer shows last track with play disabled</li>
|
|
<li>User wants to dismiss: Close button clears session -> Idle</li>
|
|
<li>Switch content types: New session replaces old (audio -> movie)</li>
|
|
<li>Paused for extended time: Session persists indefinitely</li>
|
|
<li>Playback errors: Session stays inactive, allows retry</li>
|
|
<li>Queue operations while idle: Queue exists but no session created until play</li>
|
|
</ul>
|
|
<h2 id="player-state-machine-low-level-playback"><a class="header" href="#player-state-machine-low-level-playback">Player State Machine (Low-Level Playback)</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/state.rs</code></p>
|
|
<p>The player uses a deterministic state machine with 6 states (operates within a media session):</p>
|
|
<pre><code class="language-mermaid">stateDiagram-v2
|
|
[*] --> Idle
|
|
Idle --> Loading : Load
|
|
Loading --> Playing : MediaLoaded
|
|
Playing --> Paused : Pause
|
|
Paused --> Playing : Play
|
|
Paused --> Seeking : Seek
|
|
Seeking --> Playing : PositionUpdate
|
|
Playing --> Idle : Stop
|
|
Paused --> Idle : Stop
|
|
Idle --> Error : Error
|
|
Loading --> Error : Error
|
|
Playing --> Error : Error
|
|
Paused --> Error : Error
|
|
Seeking --> Error : Error
|
|
|
|
state Playing {
|
|
[*] : position, duration
|
|
}
|
|
state Paused {
|
|
[*] : position, duration
|
|
}
|
|
state Seeking {
|
|
[*] : target
|
|
}
|
|
state Error {
|
|
[*] : error message
|
|
}
|
|
</code></pre>
|
|
<p><strong>State Enum:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub enum PlayerState {
|
|
Idle,
|
|
Loading { media: MediaItem },
|
|
Playing { media: MediaItem, position: f64, duration: f64 },
|
|
Paused { media: MediaItem, position: f64, duration: f64 },
|
|
Seeking { media: MediaItem, target: f64 },
|
|
Error { media: Option<MediaItem>, error: String },
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Event Enum:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub enum PlayerEvent {
|
|
Load(MediaItem),
|
|
Play,
|
|
Pause,
|
|
Stop,
|
|
Seek(f64),
|
|
Next,
|
|
Previous,
|
|
MediaLoaded(f64), // duration
|
|
PositionUpdate(f64), // position
|
|
PlaybackEnded,
|
|
Error(String),
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<h2 id="playback-mode-state-machine"><a class="header" href="#playback-mode-state-machine">Playback Mode State Machine</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/playback_mode/mod.rs</code></p>
|
|
<p>The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):</p>
|
|
<pre><code class="language-mermaid">stateDiagram-v2
|
|
[*] --> Idle
|
|
|
|
Idle --> Local : play_queue()
|
|
Idle --> Remote : transfer_to_remote(session_id)
|
|
|
|
Local --> Remote : transfer_to_remote(session_id)
|
|
Local --> Idle : stop()
|
|
|
|
Remote --> Local : transfer_to_local()
|
|
Remote --> Idle : session_disconnected()
|
|
Remote --> Idle : stop()
|
|
|
|
state Local {
|
|
[*] : Playing on device
|
|
[*] : ExoPlayer active
|
|
[*] : Volume buttons -> device
|
|
}
|
|
|
|
state Remote {
|
|
[*] : Controlling session
|
|
[*] : session_id
|
|
[*] : Volume buttons -> remote
|
|
[*] : Android: VolumeProvider active
|
|
}
|
|
|
|
state Idle {
|
|
[*] : No active playback
|
|
}
|
|
</code></pre>
|
|
<p><strong>State Enum:</strong></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 Jellyfin session
|
|
Idle, // No active playback
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>State Transitions:</strong></p>
|
|
<div class="table-wrapper"><table><thead><tr><th>From</th><th>Event</th><th>To</th><th>Side Effects</th></tr></thead><tbody>
|
|
<tr><td>Idle</td><td><code>play_queue()</code></td><td>Local</td><td>Start local playback</td></tr>
|
|
<tr><td>Idle</td><td><code>transfer_to_remote(session_id)</code></td><td>Remote</td><td>Send queue to remote session</td></tr>
|
|
<tr><td>Local</td><td><code>transfer_to_remote(session_id)</code></td><td>Remote</td><td>Stop local, send queue to remote, enable remote volume (Android)</td></tr>
|
|
<tr><td>Local</td><td><code>stop()</code></td><td>Idle</td><td>Stop local playback</td></tr>
|
|
<tr><td>Remote</td><td><code>transfer_to_local()</code></td><td>Local</td><td>Get remote state, stop remote, start local at same position, disable remote volume</td></tr>
|
|
<tr><td>Remote</td><td><code>stop()</code></td><td>Idle</td><td>Stop remote playback, disable remote volume</td></tr>
|
|
<tr><td>Remote</td><td><code>session_disconnected()</code></td><td>Idle</td><td>Session lost, disable remote volume</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><strong>Integration with Player State Machine:</strong></p>
|
|
<ul>
|
|
<li>When <code>PlaybackMode = Local</code>: Player state machine is active (Idle/Loading/Playing/Paused/etc.)</li>
|
|
<li>When <code>PlaybackMode = Remote</code>: Player state is typically Idle (remote session controls playback)</li>
|
|
<li>When <code>PlaybackMode = Idle</code>: Player state is Idle</li>
|
|
</ul>
|
|
<p><strong>Android Volume Control Integration:</strong></p>
|
|
<p>When transitioning to <code>Remote</code> mode on Android:</p>
|
|
<ol>
|
|
<li>Call <code>enable_remote_volume(initial_volume)</code></li>
|
|
<li>VolumeProviderCompat intercepts hardware volume buttons</li>
|
|
<li>PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)</li>
|
|
<li>Volume commands routed to remote session via Jellyfin API</li>
|
|
</ol>
|
|
<p>When transitioning away from <code>Remote</code> mode:</p>
|
|
<ol>
|
|
<li>Call <code>disable_remote_volume()</code></li>
|
|
<li>Volume buttons return to controlling device volume</li>
|
|
<li>PlaybackStateCompat set to STATE_NONE</li>
|
|
<li>VolumeProviderCompat is cleared</li>
|
|
</ol>
|
|
<h2 id="media-item--source"><a class="header" href="#media-item--source">Media Item & Source</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/media.rs</code></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub struct MediaItem {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub artist: Option<String>,
|
|
pub album: Option<String>,
|
|
pub duration: Option<f64>,
|
|
pub artwork_url: Option<String>,
|
|
pub media_type: MediaType,
|
|
pub source: MediaSource,
|
|
}
|
|
|
|
pub enum MediaType {
|
|
Audio,
|
|
Video,
|
|
}
|
|
|
|
pub enum MediaSource {
|
|
Remote {
|
|
stream_url: String,
|
|
jellyfin_item_id: String,
|
|
},
|
|
Local {
|
|
file_path: PathBuf,
|
|
jellyfin_item_id: Option<String>,
|
|
},
|
|
DirectUrl {
|
|
url: String,
|
|
},
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p>The <code>MediaSource</code> enum enables:</p>
|
|
<ul>
|
|
<li><strong>Remote</strong>: Streaming from Jellyfin server</li>
|
|
<li><strong>Local</strong>: Downloaded/cached files (future offline support)</li>
|
|
<li><strong>DirectUrl</strong>: Direct URLs (channel plugins, external sources)</li>
|
|
</ul>
|
|
<h2 id="queue-manager"><a class="header" href="#queue-manager">Queue Manager</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/queue.rs</code></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub struct QueueManager {
|
|
items: Vec<MediaItem>,
|
|
current_index: Option<usize>,
|
|
shuffle: bool,
|
|
repeat: RepeatMode,
|
|
shuffle_order: Vec<usize>, // Fisher-Yates permutation
|
|
history: Vec<usize>, // For back navigation in shuffle
|
|
}
|
|
|
|
pub enum RepeatMode {
|
|
Off,
|
|
All,
|
|
One,
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Queue Navigation Logic:</strong></p>
|
|
<pre><code class="language-mermaid">flowchart TB
|
|
QM[QueueManager]
|
|
QM --> Shuffle
|
|
QM --> Repeat
|
|
QM --> History
|
|
|
|
subgraph Shuffle["Shuffle Mode"]
|
|
ShuffleOff["OFF<br/>next() returns index + 1"]
|
|
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
|
|
end
|
|
|
|
subgraph Repeat["Repeat Mode"]
|
|
RepeatOff["OFF<br/>next() at end: -> None"]
|
|
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
|
|
RepeatOne["ONE<br/>next() returns same item"]
|
|
end
|
|
|
|
subgraph History["History"]
|
|
HistoryDesc["Used for previous()<br/>in shuffle mode"]
|
|
end
|
|
</code></pre>
|
|
<h2 id="favorites-system"><a class="header" href="#favorites-system">Favorites System</a></h2>
|
|
<p><strong>Location</strong>:</p>
|
|
<ul>
|
|
<li>Commands: <code>src-tauri/src/commands/favorites.rs</code> (offline drain),
|
|
<code>src-tauri/src/commands/repository.rs</code> (query + toggle),
|
|
<code>src-tauri/src/commands/storage/</code> (local <code>user_data</code> writes)</li>
|
|
<li>Repository: <code>get_favorites</code> on the trait, implemented by <code>online.rs</code>,
|
|
<code>offline.rs</code> and <code>hybrid.rs</code></li>
|
|
<li>Frontend: <code>src/lib/services/favorites.ts</code>,
|
|
<code>src/lib/components/FavoriteButton.svelte</code>, <code>/library/favorites</code></li>
|
|
</ul>
|
|
<p>Favouriting has two halves that are easy to confuse: <strong>marking</strong> an item, which
|
|
has existed since UR-017, and <strong>browsing</strong> what was marked, which arrived with
|
|
UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.</p>
|
|
<h3 id="marking"><a class="header" href="#marking">Marking</a></h3>
|
|
<p>Optimistic local write, then server sync:</p>
|
|
<pre><code class="language-mermaid">flowchart TB
|
|
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
|
|
Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
|
|
Service -->|"2. Sync"| Repo[Repository]
|
|
Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
|
|
Service -->|"3. Mark synced"| LocalDB
|
|
Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo
|
|
</code></pre>
|
|
<ol>
|
|
<li>The local row is updated immediately, so the heart fills without a round trip.</li>
|
|
<li>The repository is asked to mark or unmark on the server.</li>
|
|
<li>On success <code>pending_sync</code> is cleared; on failure the row stays pending.</li>
|
|
<li>A <strong>background drain</strong> (<code>spawn_favorites_drain</code>, started in <code>lib.rs</code> setup)
|
|
retries pending rows, so a favourite marked offline still reaches the server
|
|
(DR-120). This is the same pattern as the sync-queue drain — see
|
|
<a href="#background-workers">Background workers</a>.</li>
|
|
</ol>
|
|
<h3 id="browsing"><a class="header" href="#browsing">Browsing</a></h3>
|
|
<p><code>get_favorites(scope, options)</code> answers "what did this user favourite", across
|
|
libraries, with the <strong>scope owned by Rust</strong> — the frontend sends a
|
|
<a href="#search-scope-and-the-taxonomy-boundary"><code>SearchScope</code></a> variant and never names
|
|
an item type. <code>HybridRepository</code> splits it the same way it splits every query:</p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Method</th><th>Used for</th></tr></thead><tbody>
|
|
<tr><td><code>get_favorites_cache_only</code></td><td>The instant leg — the local <code>user_data</code> join</td></tr>
|
|
<tr><td><code>get_favorites_server_only</code></td><td>The reconciliation leg</td></tr>
|
|
<tr><td><code>get_favorites</code></td><td>Cache-first with server merge, per the repository's usual policy</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><code>GetItemsOptions.favorites_only</code> is the other entry point: it filters an
|
|
<em>existing</em> library listing rather than starting a cross-library query (DR-116),
|
|
which is what a library page's favourites filter uses.</p>
|
|
<p>Server favourite state is mirrored into the local <code>user_data</code> table on catalog
|
|
sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up
|
|
here — before this, <code>MediaItem.user_data</code> was left empty and no query anywhere
|
|
asked for favourites.</p>
|
|
<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>repository_get_favorites</code></td><td>Cross-library favourites for a scope</td></tr>
|
|
<tr><td><code>repository_mark_favorite</code> / <code>repository_unmark_favorite</code></td><td>Toggle on the server, through the repository</td></tr>
|
|
<tr><td><code>storage_toggle_favorite</code></td><td>Local optimistic write (<code>is_favorite</code>, <code>pending_sync</code>)</td></tr>
|
|
<tr><td><code>storage_mark_synced</code></td><td>Clear <code>pending_sync</code> after a successful server write</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p><strong>Frontend surfaces</strong> (DR-117 … DR-119): the <code>/library/favorites</code> page with a
|
|
scope selector, favourite rows on home (<code>favoriteMovies</code> / <code>favoriteShows</code> /
|
|
<code>favoriteMusic</code> in <code>stores/home.ts</code>), a favourites tile per category in the
|
|
library mosaic, and <code>FavoriteButton</code> mounted wherever a whole item is shown —
|
|
movie, series, episode, album, artist and playlist detail views as well as the
|
|
mini player.</p>
|
|
<h2 id="player-backend-trait"><a class="header" href="#player-backend-trait">Player Backend Trait</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/backend.rs</code></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub trait PlayerBackend: Send + Sync {
|
|
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
|
fn play(&mut self) -> Result<(), PlayerError>;
|
|
fn pause(&mut self) -> Result<(), PlayerError>;
|
|
fn stop(&mut self) -> Result<(), PlayerError>;
|
|
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
|
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
|
fn position(&self) -> f64;
|
|
fn duration(&self) -> Option<f64>;
|
|
fn state(&self) -> PlayerState;
|
|
fn is_loaded(&self) -> bool;
|
|
fn volume(&self) -> f32;
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Implementations:</strong></p>
|
|
<ul>
|
|
<li><code>NullBackend</code> - Mock backend for testing</li>
|
|
<li><code>MpvBackend</code> - Linux playback via libmpv (see <a href="05-platform-backends.html">05-platform-backends.md</a>)</li>
|
|
<li><code>ExoPlayerBackend</code> - Android playback via ExoPlayer/Media3 (see <a href="05-platform-backends.html">05-platform-backends.md</a>)</li>
|
|
</ul>
|
|
<h2 id="player-controller"><a class="header" href="#player-controller">Player Controller</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/player/mod.rs</code></p>
|
|
<p>The <code>PlayerController</code> orchestrates playback:</p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub struct PlayerController {
|
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
|
queue: Arc<Mutex<QueueManager>>,
|
|
muted: bool,
|
|
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
|
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
|
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
|
|
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
|
// ... other fields
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Key Methods:</strong></p>
|
|
<ul>
|
|
<li><code>play_item(item)</code>: Load and play single item (resets autoplay counter)</li>
|
|
<li><code>play_queue(items, start_index)</code>: Load queue and start playback (resets autoplay counter)</li>
|
|
<li><code>next()</code> / <code>previous()</code>: Queue navigation (resets autoplay counter)</li>
|
|
<li><code>toggle_shuffle()</code> / <code>cycle_repeat()</code>: Mode changes</li>
|
|
<li><code>set_sleep_timer(mode)</code> / <code>cancel_sleep_timer()</code>: Sleep timer control</li>
|
|
<li><code>on_playback_ended()</code>: Autoplay decision making (checks sleep timer, episode limit, queue)</li>
|
|
</ul>
|
|
<h2 id="playlist-system"><a class="header" href="#playlist-system">Playlist System</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/commands/playlist.rs</code>, <code>src-tauri/src/repository/</code></p>
|
|
<p><strong>TRACES</strong>: UR-014 | JA-019 | JA-020</p>
|
|
<p>The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.</p>
|
|
<p><strong>Types:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>/// A media item within a playlist, with its distinct playlist entry ID
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PlaylistEntry {
|
|
/// Jellyfin's PlaylistItemId (distinct from the media item ID)
|
|
pub playlist_item_id: String,
|
|
#[serde(flatten)]
|
|
pub item: MediaItem,
|
|
}
|
|
|
|
/// Result of creating a new playlist
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PlaylistCreatedResult {
|
|
pub id: String,
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Key Design Decision</strong>: <code>PlaylistEntry</code> wraps a <code>MediaItem</code> with a distinct <code>playlist_item_id</code>. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.</p>
|
|
<p><strong>MediaRepository Trait Methods:</strong></p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
|
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
|
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
|
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
|
|
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
|
|
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p><strong>Cache Strategy:</strong></p>
|
|
<ul>
|
|
<li><strong>Write operations</strong> (create, delete, rename, add, remove, move): Delegate directly to online repository</li>
|
|
<li><strong>Read operation</strong> (<code>get_playlist_items</code>): Uses cache-first parallel racing (100ms cache timeout, server fallback)</li>
|
|
<li>Background cache update after server fetch via <code>save_playlist_items_to_cache()</code></li>
|
|
</ul>
|
|
<p><strong>Playlist Tauri Commands:</strong></p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Command</th><th>Parameters</th><th>Returns</th></tr></thead><tbody>
|
|
<tr><td><code>playlist_create</code></td><td><code>handle, name, item_ids?</code></td><td><code>PlaylistCreatedResult</code></td></tr>
|
|
<tr><td><code>playlist_delete</code></td><td><code>handle, playlist_id</code></td><td><code>()</code></td></tr>
|
|
<tr><td><code>playlist_rename</code></td><td><code>handle, playlist_id, name</code></td><td><code>()</code></td></tr>
|
|
<tr><td><code>playlist_get_items</code></td><td><code>handle, playlist_id</code></td><td><code>Vec<PlaylistEntry></code></td></tr>
|
|
<tr><td><code>playlist_add_items</code></td><td><code>handle, playlist_id, item_ids</code></td><td><code>()</code></td></tr>
|
|
<tr><td><code>playlist_remove_items</code></td><td><code>handle, playlist_id, entry_ids</code></td><td><code>()</code></td></tr>
|
|
<tr><td><code>playlist_move_item</code></td><td><code>handle, playlist_id, item_id, new_index</code></td><td><code>()</code></td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<h2 id="tauri-commands-player"><a class="header" href="#tauri-commands-player">Tauri Commands (Player)</a></h2>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/commands/player.rs</code></p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Command</th><th>Parameters</th><th>Returns</th></tr></thead><tbody>
|
|
<tr><td><code>player_play_item</code></td><td><code>PlayItemRequest</code></td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_play_queue</code></td><td><code>items, start_index, shuffle</code></td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_play</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_pause</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_toggle</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_stop</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_next</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_previous</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_seek</code></td><td><code>position: f64</code></td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_set_volume</code></td><td><code>volume: f32</code></td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_toggle_shuffle</code></td><td>-</td><td><code>QueueStatus</code></td></tr>
|
|
<tr><td><code>player_cycle_repeat</code></td><td>-</td><td><code>QueueStatus</code></td></tr>
|
|
<tr><td><code>player_get_status</code></td><td>-</td><td><code>PlayerStatus</code></td></tr>
|
|
<tr><td><code>player_get_queue</code></td><td>-</td><td><code>QueueStatus</code></td></tr>
|
|
<tr><td><code>player_get_session</code></td><td>-</td><td><code>MediaSessionType</code></td></tr>
|
|
<tr><td><code>player_dismiss_session</code></td><td>-</td><td><code>()</code></td></tr>
|
|
<tr><td><code>player_set_sleep_timer</code></td><td><code>mode: SleepTimerMode</code></td><td><code>()</code></td></tr>
|
|
<tr><td><code>player_cancel_sleep_timer</code></td><td>-</td><td><code>()</code></td></tr>
|
|
<tr><td><code>player_set_video_settings</code></td><td><code>settings: VideoSettings</code></td><td><code>VideoSettings</code></td></tr>
|
|
<tr><td><code>player_get_video_settings</code></td><td>-</td><td><code>VideoSettings</code></td></tr>
|
|
<tr><td><code>player_set_autoplay_settings</code></td><td><code>settings: AutoplaySettings</code></td><td><code>AutoplaySettings</code></td></tr>
|
|
<tr><td><code>player_get_autoplay_settings</code></td><td>-</td><td><code>AutoplaySettings</code></td></tr>
|
|
<tr><td><code>player_on_playback_ended</code></td><td>-</td><td><code>()</code></td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<h2 id="domain-vocabulary-owned-by-rust"><a class="header" href="#domain-vocabulary-owned-by-rust">Domain Vocabulary Owned by Rust</a></h2>
|
|
<p>The frontend is presentation-only and must not encode Jellyfin's <em>taxonomy</em> — the
|
|
rule in <a href="../../CLAUDE.html">CLAUDE.md</a> and
|
|
<a href="../specs/scoped-search-boundary.html">scoped-search-boundary.md</a>. These are the
|
|
places where that vocabulary actually lives.</p>
|
|
<h3 id="search-scope-and-the-taxonomy-boundary"><a class="header" href="#search-scope-and-the-taxonomy-boundary">Search scope and the taxonomy boundary</a></h3>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/repository/types.rs</code></p>
|
|
<p><code>SearchScope</code> is the canonical example the boundary rule is taught from. The
|
|
frontend sends an opaque variant; Rust expands it into Jellyfin item types:</p>
|
|
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
|
</span><span class="boring">fn main() {
|
|
</span>pub enum SearchScope { All, Music, Movies, Tv }
|
|
|
|
impl SearchScope {
|
|
/// The Jellyfin item types this scope requests, or `None` for `All`.
|
|
pub fn item_types(self) -> Option<Vec<String>> { … }
|
|
|
|
/// The scope a library of this Jellyfin `CollectionType` belongs to.
|
|
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { … }
|
|
}
|
|
<span class="boring">}</span></code></pre></pre>
|
|
<p>Two details that are load-bearing:</p>
|
|
<ul>
|
|
<li><code>All</code> returns <code>None</code>, <strong>not</strong> the union of every listed type. An explicit
|
|
<code>includeItemTypes</code> list filters out anything not named in it, so a union would
|
|
silently drop People, folders, and any type nobody enumerated. Callers must
|
|
omit the filter entirely on <code>None</code>.</li>
|
|
<li><code>for_collection_type</code> maps a Jellyfin <code>CollectionType</code> to a favourites
|
|
category (DR-175). It changes when <em>Jellyfin</em> renames a collection type, not
|
|
when the library page is redesigned — which is the test for whether something
|
|
belongs on this side of the boundary.</li>
|
|
</ul>
|
|
<p>⚠️ <strong>The result side has not moved yet.</strong> <code>GROUP_ITEM_TYPES</code> in
|
|
<code>src/lib/utils/searchScope.ts</code> still maps result groups to item types in the
|
|
frontend, and <code>check:boundary</code> does not match its shape. Tracked as Stage 2 of
|
|
<a href="../specs/scoped-search-boundary-implementation.html">scoped-search-boundary-implementation.md</a>.</p>
|
|
<h3 id="library-exclusions"><a class="header" href="#library-exclusions">Library exclusions</a></h3>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/repository/exclusions.rs</code> (TRACES: UR-076 | DR-209)</p>
|
|
<p>Folders the user has chosen to keep out of music browsing — a "Podcasts" folder
|
|
inside a music library being the canonical case. Excluded <strong>by item id</strong>, not by
|
|
name, in a process-wide <code>RwLock<Vec<String>></code> restored from the database at
|
|
startup, and applied by the repository layer to every music query (libraries,
|
|
artists, albums, genres, search, home rows).</p>
|
|
<p>The id is normalised (<code>trim</code>, strip <code>-</code>, lowercase) because Jellyfin writes the
|
|
same GUID both dashed and undashed depending on the endpoint. The predecessor was
|
|
a frontend filter matching the English string "Podcasts" — wrong in three ways at
|
|
once, and the reason this lives in the repository.</p>
|
|
<p>The set is process-wide rather than a field on a repository for the same reason
|
|
as <code>online::STREAMING_QUALITY</code>: it is a preference about <em>this user's browsing</em>,
|
|
not about a server session, so it must survive a repository being rebuilt on
|
|
re-login.</p>
|
|
<h3 id="streaming-quality-ladder"><a class="header" href="#streaming-quality-ladder">Streaming quality ladder</a></h3>
|
|
<p><strong>Location</strong>: <code>src-tauri/src/settings.rs</code> (TRACES: UR-074 | DR-162)</p>
|
|
<p><code>StreamingQuality</code> is a bandwidth ladder (<code>Original</code>, 20/10/8/4/2/1 Mbps,
|
|
720 kbps), not a resolution picker: it exists to fit a connection, and the
|
|
resolution cap is chosen <em>from</em> the bitrate so the encoder does not spend a small
|
|
budget on pixels it cannot afford.</p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Method</th><th>Answers</th></tr></thead><tbody>
|
|
<tr><td><code>max_bitrate()</code></td><td>Total bits/s (video + audio), <code>None</code> for <code>Original</code></td></tr>
|
|
<tr><td><code>audio_bitrate()</code></td><td>The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom</td></tr>
|
|
<tr><td><code>video_bitrate()</code></td><td>Total minus audio, so the two together honour the ceiling</td></tr>
|
|
<tr><td><code>max_height()</code></td><td>Resolution ceiling that suits the bitrate</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<p>The ceiling goes to <code>PlaybackInfo</code> as <code>MaxStreamingBitrate</code> <strong>and</strong> into the
|
|
device profile. Sending it there — not just on the transcode URL — is what makes
|
|
the cap real: a stream the server decides to <em>direct play</em> is served at the
|
|
source file's own bitrate, and no URL parameter afterwards can reduce it.</p>
|
|
<p>The frontend names a variant and nothing else; the labels the picker shows are
|
|
served over IPC by <code>player_get_streaming_qualities</code>.</p>
|
|
<h2 id="background-workers"><a class="header" href="#background-workers">Background workers</a></h2>
|
|
<p>Three long-lived tasks are spawned from the Tauri <code>setup</code> hook in <code>lib.rs</code>. All
|
|
three exist because <em>when</em> something happens is a backend policy, not something
|
|
a page load should decide.</p>
|
|
<div class="table-wrapper"><table><thead><tr><th>Worker</th><th>Location</th><th>Responsibility</th></tr></thead><tbody>
|
|
<tr><td><code>spawn_catalog_indexer</code></td><td><code>commands/catalog.rs</code></td><td>Keeps the local FTS5 catalog fresh (DR-109, IR-030)</td></tr>
|
|
<tr><td><code>spawn_favorites_drain</code></td><td><code>commands/favorites.rs</code></td><td>Retries favourite toggles made while offline (DR-120)</td></tr>
|
|
<tr><td><code>spawn_sync_queue_drain</code></td><td><code>commands/sync_drain.rs</code></td><td>Drains the offline mutation queue (DR-131)</td></tr>
|
|
</tbody></table>
|
|
</div>
|
|
<h3 id="catalog-indexer"><a class="header" href="#catalog-indexer">Catalog indexer</a></h3>
|
|
<p>Replaces the frontend's startup-only <code>syncCatalog()</code> call. It ticks on
|
|
<code>CATALOG_INDEX_TICK</code> and runs a pass when three things hold: a repository exists,
|
|
the server is reachable, and the index is due per <code>index_is_due</code>. A tick is
|
|
nearly free — one indexed <code>app_settings</code> lookup — which is what makes it
|
|
responsive to events it cannot subscribe to, such as signing in: a fresh install
|
|
would otherwise sit unindexed until the next scheduled pass.</p>
|
|
<p><code>index_is_due</code> treats both "never indexed" and an unparseable stored timestamp as
|
|
due; a corrupt timestamp should trigger a re-index, not silently freeze the
|
|
catalog. A failed pass is never fatal — it leaves the existing index in place and
|
|
warns. Progress is emitted on <code>CATALOG_INDEX_EVENT</code> for the staleness hint in the
|
|
UI.</p>
|
|
|
|
</main>
|
|
|
|
<nav class="nav-wrapper" aria-label="Page navigation">
|
|
<!-- Mobile navigation buttons -->
|
|
<a rel="prev" href="../architecture/index.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/02-svelte-frontend.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/index.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/02-svelte-frontend.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>
|