877 lines
50 KiB
HTML
877 lines
50 KiB
HTML
<!DOCTYPE HTML>
|
||
<html lang="en" class="navy" dir="ltr">
|
||
<head>
|
||
<!-- Book generated using mdBook -->
|
||
<meta charset="UTF-8">
|
||
<title>Platform Backends - 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 "><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 expanded "><a href="../architecture/05-platform-backends.html" class="active"><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/05-platform-backends.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="platform-specific-player-backends"><a class="header" href="#platform-specific-player-backends">Platform-Specific Player Backends</a></h1>
|
||
<h2 id="player-events-system"><a class="header" href="#player-events-system">Player Events System</a></h2>
|
||
<p><strong>Location</strong>: <code>src-tauri/src/player/events.rs</code></p>
|
||
<p>The player uses a push-based event system to notify the frontend of state changes:</p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>pub enum PlayerStatusEvent {
|
||
/// Playback position updated (emitted periodically during playback)
|
||
PositionUpdate { position: f64, duration: f64 },
|
||
|
||
/// Player state changed
|
||
StateChanged { state: String, media_id: Option<String> },
|
||
|
||
/// Media has finished loading and is ready to play
|
||
MediaLoaded { duration: f64 },
|
||
|
||
/// Playback has ended naturally
|
||
PlaybackEnded,
|
||
|
||
/// Buffering state changed
|
||
Buffering { percent: u8 },
|
||
|
||
/// An error occurred during playback
|
||
Error { message: String, recoverable: bool },
|
||
|
||
/// Volume changed
|
||
VolumeChanged { volume: f32, muted: bool },
|
||
|
||
/// Sleep timer state changed
|
||
SleepTimerChanged {
|
||
mode: SleepTimerMode,
|
||
remaining_seconds: u32,
|
||
},
|
||
|
||
/// Show next episode popup with countdown
|
||
ShowNextEpisodePopup {
|
||
current_episode: MediaItem,
|
||
next_episode: MediaItem,
|
||
countdown_seconds: u32,
|
||
auto_advance: bool,
|
||
},
|
||
|
||
/// Countdown tick (emitted every second during autoplay countdown)
|
||
CountdownTick { remaining_seconds: u32 },
|
||
|
||
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
||
QueueChanged {
|
||
items: Vec<MediaItem>,
|
||
current_index: Option<usize>,
|
||
shuffle: bool,
|
||
repeat: RepeatMode,
|
||
has_next: bool,
|
||
has_previous: bool,
|
||
},
|
||
|
||
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
||
SessionChanged { session: MediaSessionType },
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
<p>Events are emitted via Tauri's event system:</p>
|
||
<pre><code class="language-mermaid">flowchart LR
|
||
subgraph Backend["Player Backend"]
|
||
MPV["MPV/ExoPlayer"]
|
||
end
|
||
|
||
subgraph EventSystem["Event System"]
|
||
Emitter["TauriEventEmitter<br/>emit()"]
|
||
Bus["Tauri Event Bus<br/>'player-event'"]
|
||
end
|
||
|
||
subgraph Frontend["Frontend"]
|
||
Listener["playerEvents.ts<br/>Frontend Listener"]
|
||
Store["Player Store Update<br/>(position, state, etc)"]
|
||
end
|
||
|
||
MPV --> Emitter --> Bus --> Listener --> Store
|
||
</code></pre>
|
||
<p><strong>Frontend Listener</strong> (<code>src/lib/services/playerEvents.ts</code>):</p>
|
||
<ul>
|
||
<li>Listens for <code>player-event</code> Tauri events</li>
|
||
<li>Updates player/queue stores based on event type</li>
|
||
<li>Auto-advances to next track on <code>PlaybackEnded</code></li>
|
||
<li>On <code>StateChanged</code> events, calls <code>invoke("player_get_queue")</code> to update <code>appState.hasNext</code>/<code>hasPrevious</code> -- this enables MiniPlayer skip button state</li>
|
||
</ul>
|
||
<p><strong>Important</strong>: The command is <code>player_get_queue</code> (returns <code>QueueStatus</code> with <code>hasNext</code>/<code>hasPrevious</code>). There is no <code>player_get_queue_status</code> command.</p>
|
||
<h2 id="html5-video-adapter-webview-rendered-video"><a class="header" href="#html5-video-adapter-webview-rendered-video">HTML5 Video Adapter (webview-rendered video)</a></h2>
|
||
<p><strong>Location</strong>: <code>src/lib/player/html5Adapter.ts</code>, <code>src/lib/player/index.ts</code>, report commands in
|
||
<code>src-tauri/src/commands/player/timers.rs</code></p>
|
||
<p>Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||
HTML5 <code><video></code>/HLS element <strong>inside the webview</strong>. libmpv is initialized audio-only (<code>vo=null</code>,
|
||
<code>video=false</code>), so the native backend cannot render or observe this element. The <code><video></code> is therefore
|
||
the real player, living outside Rust's reach.</p>
|
||
<p>To keep the <code>PlayerController</code> the single source of truth (matching the audio path), the HTML5 element
|
||
is treated as <strong>a dumb output device that reports back into Rust</strong>, rather than an independent state
|
||
authority:</p>
|
||
<pre><code class="language-mermaid">flowchart LR
|
||
subgraph Webview["Webview"]
|
||
Video["HTML5 <video> / HLS.js"]
|
||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
||
end
|
||
subgraph Backend["Rust"]
|
||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
||
Controller["PlayerController"]
|
||
Emitter["TauriEventEmitter"]
|
||
end
|
||
subgraph Frontend["Frontend"]
|
||
Events["playerEvents.ts"]
|
||
Store["player store"]
|
||
end
|
||
|
||
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
||
</code></pre>
|
||
<p><strong>Key points:</strong></p>
|
||
<ul>
|
||
<li>The adapter re-emits the <em>same</em> <code>PlayerStatusEvent</code>s (<code>StateChanged</code>, <code>PositionUpdate</code>, <code>MediaLoaded</code>)
|
||
the native backends emit, so <code>playerEvents.ts</code> needs <strong>no</strong> HTML5-specific branch — HTML5 is just
|
||
another event source feeding the existing pipeline.</li>
|
||
<li>Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
||
60fps RAF loop.</li>
|
||
<li><strong>Boundary rule</strong>: UI components never touch the report commands or <code>videoElement</code> state directly.
|
||
Playback <em>control</em> goes through the unified facade <code>src/lib/player/index.ts</code> (<code>playerController</code>);
|
||
HTML5 <em>state reporting</em> goes through <code>html5Adapter.ts</code>. This restores the documented invariant
|
||
("frontend only displays state and invokes commands") for the video path.</li>
|
||
</ul>
|
||
<h2 id="mpvbackend-linux"><a class="header" href="#mpvbackend-linux">MpvBackend (Linux)</a></h2>
|
||
<p><strong>Location</strong>: <code>src-tauri/src/player/mpv/</code></p>
|
||
<p>The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not <code>Send</code>, all operations occur on a dedicated thread.</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
subgraph MainThread["Main Thread"]
|
||
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
|
||
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
|
||
end
|
||
|
||
subgraph EventLoopThread["MPV Event Loop Thread"]
|
||
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
|
||
TauriEmitter["TauriEventEmitter"]
|
||
end
|
||
|
||
MpvBackend -->|"MpvCommand"| EventLoop
|
||
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
|
||
EventLoop -->|"Events"| TauriEmitter
|
||
TauriEmitter --> FrontendStore["Frontend Store"]
|
||
</code></pre>
|
||
<p><strong>Key Components:</strong></p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>// Command enum sent to event loop thread
|
||
pub enum MpvCommand {
|
||
Load { url: String, media: MediaItem },
|
||
Play,
|
||
Pause,
|
||
Stop,
|
||
Seek(f64),
|
||
SetVolume(f32),
|
||
Quit,
|
||
}
|
||
|
||
// Shared state between main thread and event loop
|
||
pub struct MpvSharedState {
|
||
pub state: PlayerState,
|
||
pub position: f64,
|
||
pub duration: Option<f64>,
|
||
pub volume: f32,
|
||
pub is_loaded: bool,
|
||
pub current_media: Option<MediaItem>,
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
<p><strong>Event Loop</strong> (<code>event_loop.rs</code>):</p>
|
||
<ul>
|
||
<li>Initializes MPV with audio-only config (<code>vo=null</code>, <code>video=false</code>)</li>
|
||
<li>Observes properties: <code>time-pos</code>, <code>duration</code>, <code>pause</code>, <code>volume</code></li>
|
||
<li>Emits position updates every 250ms during playback</li>
|
||
<li>Processes commands from channel (non-blocking)</li>
|
||
<li>Handles MPV events: <code>FileLoaded</code>, <code>EndFile</code>, <code>PropertyChange</code></li>
|
||
</ul>
|
||
<h2 id="exoplayerbackend-android"><a class="header" href="#exoplayerbackend-android">ExoPlayerBackend (Android)</a></h2>
|
||
<p><strong>Location</strong>: <code>src-tauri/src/player/android/</code> and Kotlin sources</p>
|
||
<p>The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
subgraph RustNative["Rust (Native)"]
|
||
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
|
||
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
|
||
TauriEmitter2["TauriEventEmitter"]
|
||
end
|
||
|
||
subgraph KotlinJVM["Kotlin (JVM)"]
|
||
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
|
||
end
|
||
|
||
ExoBackend -->|"JNI Calls"| JellyTauPlayer
|
||
JellyTauPlayer -->|"Callbacks"| NativeFuncs
|
||
NativeFuncs --> TauriEmitter2
|
||
TauriEmitter2 --> FrontendStore2["Frontend Store"]
|
||
</code></pre>
|
||
<p><strong>Kotlin Player</strong> (<code>JellyTauPlayer.kt</code>):</p>
|
||
<pre><code class="language-kotlin">class JellyTauPlayer(context: Context) {
|
||
private val exoPlayer: ExoPlayer
|
||
private var positionUpdateJob: Job?
|
||
|
||
// Methods callable from Rust via JNI
|
||
fun load(url: String, mediaId: String)
|
||
fun play()
|
||
fun pause()
|
||
fun stop()
|
||
fun seek(positionSeconds: Double)
|
||
fun setVolume(volume: Float)
|
||
|
||
// Native callbacks to Rust
|
||
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
||
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
||
private external fun nativeOnMediaLoaded(duration: Double)
|
||
private external fun nativeOnPlaybackEnded()
|
||
}
|
||
</code></pre>
|
||
<p><strong>JNI Callbacks</strong> (Rust):</p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>#[no_mangle]
|
||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
|
||
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
|
||
) {
|
||
// Update shared state
|
||
// Emit PlayerStatusEvent::PositionUpdate
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
<h3 id="audio-settings-on-exoplayer"><a class="header" href="#audio-settings-on-exoplayer">Audio settings on ExoPlayer</a></h3>
|
||
<p><strong>TRACES</strong>: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036</p>
|
||
<p><code>PlayerBackend</code> declares <code>set_audio_settings</code> with a default <code>Ok(())</code> body. For a
|
||
long time <code>ExoPlayerBackend</code> took that default, so Settings › Audio rendered
|
||
controls that silently did nothing on Android — the parity gap recorded in
|
||
<a href="../requirements.html#platform-playback-backend-parity-linux-vs-android">requirements.md</a>,
|
||
now closed.</p>
|
||
<p>The settings cross to Kotlin as <strong>JSON over JNI</strong>, not as a wide signature, so new
|
||
fields do not change the method signature — the same approach <code>load()</code> uses for
|
||
subtitles:</p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||
let json = audio_settings_jni_payload(settings)?;
|
||
env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?;
|
||
// Store the sanitised form, so audio_settings() reflects what was applied.
|
||
self.shared_state.lock_safe().audio_settings =
|
||
settings.clone().with_crossfade_clamped().with_equalizer_normalised();
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
<p>Kotlin owns the <em>mechanics</em> — attaching <code>AudioEffect</code>s to the audio session — while
|
||
the canonical band layout and preset curves stay in Rust:</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Feature</th><th>Android mechanism</th><th>Notes</th></tr></thead><tbody>
|
||
<tr><td>Gapless</td><td><code>pauseAtEndOfMediaItems</code></td><td></td></tr>
|
||
<tr><td>Volume normalization</td><td><code>LoudnessEnhancer</code></td><td>A gain stage — approximate next to MPV's <code>dynaudnorm</code></td></tr>
|
||
<tr><td>Equalizer</td><td><code>android.media.audiofx.Equalizer</code></td><td>The canonical 10 bands are resampled onto the device's own band centres</td></tr>
|
||
<tr><td>Crossfade</td><td>—</td><td>Unimplemented on <strong>every</strong> platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p>Two things are deliberately still open: the effects are <strong>not yet verified on a
|
||
physical device</strong> (<code>AudioEffect</code> availability and band layouts are device-specific),
|
||
and the trait default is still a silent <code>Ok(())</code> rather than an error, so a backend
|
||
that omits the method still reports success. Flipping that default waits on the
|
||
device verification.</p>
|
||
<h3 id="the-equalizer-and-where-its-vocabulary-lives"><a class="header" href="#the-equalizer-and-where-its-vocabulary-lives">The equalizer, and where its vocabulary lives</a></h3>
|
||
<p><strong>TRACES</strong>: UR-027 | DR-030, IR-020</p>
|
||
<p>The canonical band layout (<code>EQ_BANDS</code>) and the preset curves live in
|
||
<code>settings.rs</code>, <strong>not</strong> in either backend and not in the UI: a preset <em>is</em> a gain
|
||
curve defined by the band layout, and the layout is a property of the audio
|
||
engine rather than of the picker that renders it. Presets are Flat, Rock, Pop,
|
||
Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within
|
||
±8 dB) so they stack safely with volume normalization.</p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Platform</th><th>Mechanism</th></tr></thead><tbody>
|
||
<tr><td>Linux</td><td>One ffmpeg two-pole peaking <code>equalizer</code> filter per band, composed by <code>build_af_filter</code> into MPV's <code>af</code> property alongside the normalization filter: <code>equalizer=f=31:width_type=o:width=1:g=5</code></td></tr>
|
||
<tr><td>Android</td><td><code>android.media.audiofx.Equalizer</code>, with the canonical 10 bands <strong>resampled onto whatever band centres the device actually has</strong></td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p>Gains are normalised (<code>with_equalizer_normalised</code>) before use, and bands beyond
|
||
<code>EQ_BANDS</code> are ignored, so a malformed settings payload cannot produce a filter
|
||
chain of unbounded length.</p>
|
||
<h2 id="background-audio-handoff-android"><a class="header" href="#background-audio-handoff-android">Background Audio Handoff (Android)</a></h2>
|
||
<p><strong>TRACES</strong>: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203</p>
|
||
<p>Keeping a video's <strong>audio</strong> alive when the app is backgrounded or the screen
|
||
locks, while video decode stops. Two verified facts drive the whole design:</p>
|
||
<ol>
|
||
<li>An Android WebView <code><video></code> <strong>does not</strong> keep playing audio once the app is
|
||
backgrounded — the system throttles the WebView and media pauses.</li>
|
||
<li>Keeping audio alive in the background requires a <strong>native foreground media
|
||
service</strong>, which already exists for music (<code>JellyTauPlaybackService</code> +
|
||
<code>JellyTauPlayer</code> + <code>MediaSessionCompat</code>).</li>
|
||
</ol>
|
||
<p>So this is a <strong>handoff</strong>, not "keep the WebView alive": on background, tear down
|
||
the current renderer and play the same item audio-only through the native
|
||
service; on foreground, hand back. In the project's one-directional playback
|
||
model this is a change of <em>which player is authoritative</em>, and the position must
|
||
transfer cleanly across it.</p>
|
||
<pre><code class="language-mermaid">sequenceDiagram
|
||
participant App as App backgrounded
|
||
participant FE as VideoPlayer
|
||
participant Rust as player_enter/exit_background_audio
|
||
participant Exo as Native audio service
|
||
|
||
App-->>FE: jellytau-background (DOM CustomEvent)
|
||
FE->>Rust: enter(item, position, audioStreamIndex)
|
||
Rust->>Exo: play audio-only at position
|
||
Note over Exo: lockscreen + notification, existing MediaSession
|
||
App-->>FE: jellytau-foreground
|
||
FE->>Rust: exit() -> final position
|
||
Rust-->>FE: position
|
||
FE->>FE: restart the renderer that is on screen
|
||
</code></pre>
|
||
<p>Details that were each a shipped defect:</p>
|
||
<ul>
|
||
<li><strong>Position is absolute.</strong> Transcoded HLS tracks time as
|
||
<code>videoElement.currentTime + seekOffset</code> (the element resets to 0 after each
|
||
transcode reload). <code>computeHandoffPosition</code> sums both terms; using the element
|
||
time alone rewinds by the offset.</li>
|
||
<li><strong>A downloaded episode takes no base URL and an ordinary seek</strong> (DR-180); a
|
||
stream takes the base and no seek; a handoff at 0:00 takes neither.</li>
|
||
<li><strong>The return must restart the renderer that is actually on screen</strong> (DR-196).
|
||
The two paths resume by different means — the webview <code><video></code> reloads off its
|
||
stream URL, watched by an <code>$effect</code>; ExoPlayer owns no element and nothing
|
||
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
|
||
assignment restarted nothing on the native path and left a black screen with a
|
||
play button that did nothing.</li>
|
||
<li><strong><code>wasPlaying</code> is captured on the way out</strong> so play/pause survives the round
|
||
trip, and the handoff does not silently rewind (DR-203).</li>
|
||
<li><strong>Mutually exclusive with PiP.</strong> Toggle on → <code>setAutoEnterEnabled(false)</code>;
|
||
toggle off → PiP on background, the status quo. The frontend re-asserts the
|
||
value whenever the toggle changes and on unmount, so a stale setting cannot
|
||
leak into the next player.</li>
|
||
<li>The pure arithmetic and state transitions live in
|
||
<code>backgroundAudioHandoff.ts</code>, free of Svelte and the DOM, so they are testable
|
||
without mounting the player.</li>
|
||
</ul>
|
||
<p>Native signals background/foreground to the frontend as DOM CustomEvents
|
||
(<code>jellytau-background</code> / <code>jellytau-foreground</code>); the frontend carries the toggle
|
||
state to native through the <code>AndroidBackgroundAudio</code> bridge. No-op on every
|
||
non-Android platform.</p>
|
||
<h2 id="native-video-compositing-android"><a class="header" href="#native-video-compositing-android">Native Video Compositing (Android)</a></h2>
|
||
<p><strong>TRACES</strong>: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196</p>
|
||
<p>Android can render video on the <strong>native ExoPlayer surface behind a transparent
|
||
Tauri WebView</strong>, with the Svelte controls drawn over it. This is on by default;
|
||
the HTML5 <code><video></code> path remains the fallback and is not being removed. The
|
||
default has been flipped and reverted twice and each revert has a named cause —
|
||
the per-defect record is in <code>requirements.md</code> (DR-150 … DR-196).</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
subgraph Window["One Android window"]
|
||
Texture["TextureView (index 0)<br/>ExoPlayer video"]
|
||
WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
|
||
end
|
||
Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
|
||
Player --> Texture
|
||
MainActivity -->|"setTransparent(true)"| WebView
|
||
VideoOverlayManager -->|"attach / detach"| Texture
|
||
</code></pre>
|
||
<p>Load-bearing details, each of which was a shipped defect:</p>
|
||
<ul>
|
||
<li><strong>TextureView, not SurfaceView</strong> (DR-192). A SurfaceView renders on its own
|
||
layer <em>outside</em> the app window and punches a transparent hole through it;
|
||
everything drawn above that hole — for us the whole UI — depends on that
|
||
composition path, which Android's own documentation says does not reliably
|
||
work. A TextureView makes "behind" ordinary view z-order within one window.</li>
|
||
<li><strong>Attached at index 0</strong> by <code>VideoOverlayManager</code>, and <strong>detached when the video
|
||
goes</strong> (DR-184) — a surface left in the hierarchy outlives its player.</li>
|
||
<li><strong>Bridges are installed before the page that uses them</strong> (DR-183).
|
||
<code>addJavascriptInterface</code> must run once per WebView instance and a call that
|
||
lands after the page has loaded never reaches it, so <code>setTransparent(true)</code>
|
||
could be dropped entirely.</li>
|
||
<li><strong>The app shell stops painting over the surface</strong> (DR-185). <code>app.css</code> clears
|
||
its opaque backgrounds off <code>[data-native-video]</code>; before that, a CSS rule
|
||
targeted an attribute nothing ever set, so the fix looked applied and was not.</li>
|
||
<li><strong>The poster card can lift on a path with no <code><video></code> element</strong> (DR-182) — the
|
||
native reveal fires on a <code>playing</code> state or a position tick carrying a position
|
||
or duration, and on nothing else.</li>
|
||
<li><strong>Letterbox bars are painted</strong>, not left holding whatever was last in the
|
||
framebuffer (DR-194).</li>
|
||
<li>There is deliberately <strong>no audio-focus bridge</strong>: manual focus requests from the
|
||
WebView competed with Chromium's <code>AudioFocusDelegate</code> and with ExoPlayer, and
|
||
the resulting <code>AUDIOFOCUS_LOSS</code> paused playback.</li>
|
||
</ul>
|
||
<p>Related Kotlin pieces in the same window: <code>PictureInPictureManager</code> (DR-160/161),
|
||
<code>ScreenWakeManager</code> (DR-202 — Android counts its display timeout from touch
|
||
events, which a playing video does not generate), <code>ImmersiveModeBridge</code> and
|
||
<code>WindowInsetsBridge</code> (IR-031/DR-112 — see
|
||
<a href="02-svelte-frontend.html#safe-area-insets">02-svelte-frontend.md</a>).</p>
|
||
<h2 id="android-mediasession--remote-volume-control"><a class="header" href="#android-mediasession--remote-volume-control">Android MediaSession & Remote Volume Control</a></h2>
|
||
<p><strong>Location</strong>: <code>JellyTauPlaybackService.kt</code></p>
|
||
<p>JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
subgraph Service["JellyTauPlaybackService"]
|
||
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
|
||
|
||
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
|
||
|
||
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
|
||
|
||
MediaSessionCompat --> VolumeProvider
|
||
end
|
||
|
||
subgraph Hardware["System"]
|
||
VolumeButtons["Hardware Volume Buttons"]
|
||
Lockscreen["Lockscreen Controls"]
|
||
Notification["Media Notification"]
|
||
end
|
||
|
||
subgraph Rust["Rust Backend"]
|
||
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
|
||
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
|
||
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
|
||
end
|
||
|
||
VolumeButtons --> VolumeProvider
|
||
Lockscreen --> MediaSession
|
||
Notification --> MediaSession
|
||
|
||
VolumeProvider --> JNI
|
||
JNI --> PlaybackMode
|
||
PlaybackMode --> JellyfinAPI
|
||
</code></pre>
|
||
<p><strong>Architecture Rationale:</strong></p>
|
||
<p>JellyTau maintains both MediaSession types because they serve different purposes:</p>
|
||
<ol>
|
||
<li><strong>Media3 MediaSession</strong>: Handles lockscreen/notification playback controls (play/pause/next/previous)</li>
|
||
<li><strong>MediaSessionCompat</strong>: Intercepts hardware volume button presses for remote playback control</li>
|
||
</ol>
|
||
<p>When in remote playback mode (controlling a Jellyfin session on another device):</p>
|
||
<ul>
|
||
<li>Volume buttons are routed through <code>VolumeProviderCompat</code></li>
|
||
<li>Volume changes are sent to the remote session via Jellyfin API</li>
|
||
<li>System volume UI shows the remote session's volume level</li>
|
||
</ul>
|
||
<p><strong>Remote Volume Flow:</strong></p>
|
||
<pre><code class="language-mermaid">sequenceDiagram
|
||
participant User
|
||
participant VolumeButton as Hardware Volume Button
|
||
participant VolumeProvider as VolumeProviderCompat
|
||
participant JNI as nativeOnRemoteVolumeChange
|
||
participant PlaybackMode as PlaybackModeManager
|
||
participant Jellyfin as Jellyfin Server
|
||
participant RemoteSession as Remote Session (TV/Browser)
|
||
|
||
User->>VolumeButton: Press Volume Up
|
||
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
|
||
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
|
||
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
|
||
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
|
||
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
|
||
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
|
||
Jellyfin->>RemoteSession: Set volume to new level
|
||
RemoteSession-->>User: Volume changes on TV/Browser
|
||
</code></pre>
|
||
<p><strong>Key Implementation Details:</strong></p>
|
||
<p><strong>Enabling Remote Volume</strong> (<code>enableRemoteVolume()</code>):</p>
|
||
<pre><code class="language-kotlin">fun enableRemoteVolume(initialVolume: Int) {
|
||
volumeProvider = object : VolumeProviderCompat(
|
||
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
|
||
100, // Max volume
|
||
initialVolume
|
||
) {
|
||
override fun onSetVolumeTo(volume: Int) {
|
||
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
||
}
|
||
|
||
override fun onAdjustVolume(direction: Int) {
|
||
when (direction) {
|
||
AudioManager.ADJUST_RAISE -> {
|
||
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
||
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
||
currentVolume = remoteVolumeLevel
|
||
}
|
||
AudioManager.ADJUST_LOWER -> {
|
||
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
||
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
||
currentVolume = remoteVolumeLevel
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
|
||
}
|
||
</code></pre>
|
||
<p><strong>Disabling Remote Volume</strong> (<code>disableRemoteVolume()</code>):</p>
|
||
<pre><code class="language-kotlin">fun disableRemoteVolume() {
|
||
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
||
volumeProvider = null
|
||
}
|
||
</code></pre>
|
||
<p><strong>Rust Integration</strong> (<code>src-tauri/src/player/android/mod.rs</code>):</p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>/// Enable remote volume control on Android
|
||
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||
start_playback_service()?;
|
||
let service_instance = get_playback_service_instance()?;
|
||
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
|
||
&[JValue::Int(initial_volume)])?;
|
||
Ok(())
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
<p><strong>Dependencies</strong> (<code>src-tauri/android/build.gradle.kts</code>):</p>
|
||
<pre><code class="language-kotlin">dependencies {
|
||
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
|
||
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
|
||
}
|
||
</code></pre>
|
||
<p><strong>Integration with Playback Mode:</strong></p>
|
||
<p>Remote volume is automatically enabled/disabled during playback mode transfers:</p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>// In PlaybackModeManager::transfer_to_remote()
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||
log::warn!("Failed to enable remote volume: {}", e);
|
||
}
|
||
}
|
||
|
||
// In PlaybackModeManager::transfer_to_local()
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
if let Err(e) = crate::player::disable_remote_volume() {
|
||
log::warn!("Failed to disable remote volume: {}", e);
|
||
}
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
<h2 id="android-album-art-caching"><a class="header" href="#android-album-art-caching">Android Album Art Caching</a></h2>
|
||
<p><strong>Location</strong>: <code>src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt</code></p>
|
||
<p>Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.</p>
|
||
<pre><code class="language-mermaid">flowchart TB
|
||
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
|
||
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
|
||
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
|
||
end
|
||
|
||
subgraph Cache["AlbumArtCache.kt"]
|
||
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
|
||
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
|
||
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
|
||
end
|
||
|
||
subgraph Service["JellyTauPlaybackService.kt"]
|
||
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
|
||
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
|
||
end
|
||
|
||
LoadMedia --> AsyncDownload
|
||
AsyncDownload --> MemoryCache
|
||
MemoryCache --> Download
|
||
Download --> ErrorHandle
|
||
AsyncDownload --> UpdateMeta
|
||
UpdateMeta --> Notification
|
||
</code></pre>
|
||
<p><strong>AlbumArtCache Singleton:</strong></p>
|
||
<pre><code class="language-kotlin">class AlbumArtCache(context: Context) {
|
||
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
||
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||
return bitmap.byteCount / 1024 // Size in KB
|
||
}
|
||
}
|
||
|
||
suspend fun getArtwork(url: String): Bitmap? {
|
||
memoryCache.get(url)?.let { return it }
|
||
return downloadAndCache(url)
|
||
}
|
||
|
||
private suspend fun downloadAndCache(url: String): Bitmap? =
|
||
withContext(Dispatchers.IO) {
|
||
// HTTP download with 5s timeout
|
||
// Scale to 512x512 max
|
||
// Auto-evict LRU if needed
|
||
}
|
||
}
|
||
</code></pre>
|
||
<p><strong>Integration Flow:</strong></p>
|
||
<ol>
|
||
<li>
|
||
<p><strong>Track Load</strong> (<code>loadWithMetadata()</code>):</p>
|
||
<ul>
|
||
<li>Store artwork URL in <code>currentArtworkUrl</code></li>
|
||
<li>Reset bitmap to null</li>
|
||
<li>Start playback immediately (non-blocking)</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Async Download</strong> (Background Coroutine):</p>
|
||
<ul>
|
||
<li>Check cache: instant hit if available</li>
|
||
<li>Network miss: download, scale, cache</li>
|
||
<li>Auto-retry on network failure with exponential backoff</li>
|
||
<li>Graceful fallback if artwork unavailable</li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<p><strong>Notification Update</strong>:</p>
|
||
<ul>
|
||
<li>Pass bitmap to <code>updatePlaybackServiceNotification()</code></li>
|
||
<li>Add to <code>MediaMetadataCompat</code> with <code>METADATA_KEY_ALBUM_ART</code></li>
|
||
<li>Display as large icon in notification</li>
|
||
<li>Show on lock screen</li>
|
||
</ul>
|
||
</li>
|
||
</ol>
|
||
<p><strong>Memory Management:</strong></p>
|
||
<div class="table-wrapper"><table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>
|
||
<tr><td>Cache Size</td><td>1/8 of heap (12-16MB typical)</td></tr>
|
||
<tr><td>Max Resolution</td><td>512x512 pixels</td></tr>
|
||
<tr><td>Capacity</td><td>~50-100 album arts</td></tr>
|
||
<tr><td>Eviction Policy</td><td>LRU (Least Recently Used)</td></tr>
|
||
<tr><td>Lifetime</td><td>In-memory only (app session)</td></tr>
|
||
<tr><td>Network Timeout</td><td>5 seconds per download</td></tr>
|
||
</tbody></table>
|
||
</div>
|
||
<p><strong>Performance Characteristics:</strong></p>
|
||
<ul>
|
||
<li><strong>Cache Hit</strong>: ~1ms (in-memory retrieval)</li>
|
||
<li><strong>Cache Miss</strong>: ~200-500ms (download + scale)</li>
|
||
<li><strong>Playback Impact</strong>: Zero (async downloads)</li>
|
||
<li><strong>Memory Overhead</strong>: Max 16MB (auto-eviction)</li>
|
||
<li><strong>Error Recovery</strong>: Automatic with exponential backoff</li>
|
||
</ul>
|
||
<h2 id="backend-initialization"><a class="header" href="#backend-initialization">Backend Initialization</a></h2>
|
||
<p><strong>Location</strong>: <code>src-tauri/src/lib.rs</code></p>
|
||
<p>Backend selection is platform-specific:</p>
|
||
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
|
||
</span><span class="boring">fn main() {
|
||
</span>fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
|
||
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
match MpvBackend::new(event_emitter.clone()) {
|
||
Ok(backend) => return Box::new(backend),
|
||
Err(e) => eprintln!("MPV init failed: {}", e),
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
// ExoPlayer requires Activity context, initialized separately
|
||
}
|
||
|
||
// Fallback
|
||
Box::new(NullBackend::new())
|
||
}
|
||
<span class="boring">}</span></code></pre></pre>
|
||
|
||
</main>
|
||
|
||
<nav class="nav-wrapper" aria-label="Page navigation">
|
||
<!-- Mobile navigation buttons -->
|
||
<a rel="prev" href="../architecture/04-type-sync-and-threading.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/06-downloads-and-offline.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/04-type-sync-and-threading.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/06-downloads-and-offline.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>
|