Files
jellytau/architecture/05-platform-backends.html
T

919 lines
55 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE HTML>
<html lang="en" class="navy" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>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/desktop-native-video.html"><strong aria-hidden="true">21.</strong> Desktop Native Video</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">22.</strong> Build Provenance</a></li><li class="chapter-item expanded affix "><li class="part-title">Build & Release</li><li class="chapter-item expanded "><a href="../build/build-release.html"><strong aria-hidden="true">23.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">24.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../native-player-verification.html"><strong aria-hidden="true">25.</strong> Native Player Verification</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">26.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">27.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">28.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">29.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">30.</strong> Builder Image</a></li><li class="chapter-item expanded affix "><li class="spacer"></li><li class="chapter-item expanded affix "><a href="../api-redirect.html">Rust API Reference (rustdoc)</a></li></ol>
</div>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<!-- Track and set sidebar scroll position -->
<script>
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
sidebarScrollbox.addEventListener('click', function(e) {
if (e.target.tagName === 'A') {
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
}
}, { passive: true });
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
sessionStorage.removeItem('sidebar-scroll');
if (sidebarScrollTop) {
// preserve sidebar scroll position when navigating via links within sidebar
sidebarScrollbox.scrollTop = sidebarScrollTop;
} else {
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
var activeSection = document.querySelector('#sidebar .active');
if (activeSection) {
activeSection.scrollIntoView({ block: 'center' });
}
}
</script>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
</div>
<h1 class="menu-title">JellyTau Documentation</h1>
<div class="right-buttons">
<a href="../print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
<a href="https://gitea.tourolle.paris/dtourolle/jellytau" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa fa-github"></i>
</a>
<a href="https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/../docs/architecture/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&lt;String&gt; },
/// 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&lt;MediaItem&gt;,
current_index: Option&lt;usize&gt;,
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&lt;br/&gt;emit()"]
Bus["Tauri Event Bus&lt;br/&gt;'player-event'"]
end
subgraph Frontend["Frontend"]
Listener["playerEvents.ts&lt;br/&gt;Frontend Listener"]
Store["Player Store Update&lt;br/&gt;(position, state, etc)"]
end
MPV --&gt; Emitter --&gt; Bus --&gt; Listener --&gt; 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="video-is-always-native-no-webview-video"><a class="header" href="#video-is-always-native-no-webview-video">Video is always native (no webview <code>&lt;video&gt;</code>)</a></h2>
<p><strong>TRACES</strong>: UR-080 | DR-231, DR-235, DR-237</p>
<p>Every video renderer is a native player drawing <strong>behind</strong> the transparent
webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
Linux and Windows. There is no webview <code>&lt;video&gt;</code> element, no hls.js, and no
HTML5 adapter; the frontend has one video adapter, <code>NativePlayerAdapter</code>, and the
backend performs every seek, track switch and quality change itself.</p>
<p>Why the webview path was deleted rather than kept as a fallback:</p>
<ul>
<li><strong>The transcode was a decoder constraint.</strong> The <code>&lt;video&gt;</code> element decodes
little beyond h264, so the desktop profile could only claim h264 and the server
re-encoded almost everything (7% direct play on a real library, against 85% for
the same library through ExoPlayer). The machine was never the limit — mpv was
already running for audio. (The Linux/Windows profile still claims h264 until
DR-234 widens it; see <a href="../specs/desktop-native-video.html">desktop-native-video.md</a>.)</li>
<li><strong>Each renderer is another place for every bug.</strong> Three video renderers meant
every seek strategy, track switch and lifecycle fix had three places to be got
right; the webview path was also where the renderer choice itself went wrong
(silent Linux video, a Windows soundtrack decoded twice — DR-237).</li>
<li><strong>A fallback that decodes less is not a fallback.</strong> Android showed it first
(DR-293): an original-file download plays silent in the webview. With no
webview path there is no silent downgrade to fall into; a failed mpv init
emits <code>backend-init-failed</code> instead.</li>
</ul>
<p>What survives of the webview reporting pipeline is audio-only:
<code>WebviewAudioBackend</code> plays through a hidden <code>&lt;audio&gt;</code> element and reports back
through the <code>player_report_*</code> commands (<code>rustReportHost.ts</code>), for a desktop with
no mpv. No shipped platform uses it.</p>
<h2 id="native-video-on-the-desktop-mpv"><a class="header" href="#native-video-on-the-desktop-mpv">Native video on the desktop (mpv)</a></h2>
<p><strong>TRACES</strong>: UR-080 | DR-231 … DR-237, DR-298, DR-299</p>
<p>The mpv half is shared; only the surface differs per platform
(<code>mpv_backend::video_output</code>):</p>
<div class="table-wrapper"><table><thead><tr><th>Platform</th><th>Output</th><th>Where the picture goes</th></tr></thead><tbody>
<tr><td>Linux</td><td><code>vo=libmpv</code> (render API)</td><td>An FBO drawn in the main window's own <code>GtkBox</code> <code>draw</code> handler, which GTK paints <em>before</em> its children — so beneath the webview, with no widget reparenting (<code>video_surface.rs</code>; a <code>GtkOverlay</code> aborts the process on the first click, see that module)</td></tr>
<tr><td>Windows</td><td><code>vo=gpu-next,gpu</code>, <code>wid=&lt;HWND&gt;</code></td><td>mpv renders as a child of the app window, beneath the transparent WebView2 — the arrangement tauri-plugin-libmpv ships. <code>wid</code> only takes effect before initialisation, so <code>MpvBackend::new</code> takes the handle; with no handle it draws nothing rather than open a window of its own. mpv's controller, bindings and cursor handling are off</td></tr>
</tbody></table>
</div>
<p>In both, the page clears its opaque backgrounds while a video is on screen
(<code>data-native-video</code>, the same CSS Android uses).</p>
<p><strong>Tracks</strong> (<code>mpv_tracks.rs</code>): subtitles are the WebVTT list the play request
carries, queued on <code>sub-files</code> before the load and selected <strong>by position in
that list</strong> — the same meaning ExoPlayer gives <code>player_set_subtitle_track</code>.
Selection starts off (the menu opens on "Off") and <code>sid</code>/<code>aid</code> are reset before
every load, so a choice made for one item cannot leak into the next. Audio tracks
are selected by position in the file; a transcode carries one track and is
re-opened instead.</p>
<p><strong>Two rules for every libmpv handle</strong> (<code>mpv_command.rs</code>):</p>
<ul>
<li>Commands go through <code>mpv_command::command</code>, an argv built for <code>mpv_command</code>,
never the pinned crate's <code>Mpv::command</code>, which joins its arguments into a
string that mpv parses — <code>;</code> chains a second command, so a track title in a
downloaded file's path could run <code>run …</code> (DR-298).</li>
<li>Every handle is hardened before its first load: <code>tls-verify=yes</code> (mpv's
default is <em>no</em>, and its URLs carry the <code>ApiKey</code>) and <code>ytdl=no</code> (DR-299).</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 <strong>and video</strong> playback on Linux and Windows (video: see <em>Native video on the desktop</em> above). 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&lt;br/&gt;- command_tx&lt;br/&gt;- shared_state&lt;br/&gt;- shutdown"]
Commands["Commands:&lt;br/&gt;Load, Play, Pause&lt;br/&gt;Stop, Seek, SetVolume"]
end
subgraph EventLoopThread["MPV Event Loop Thread"]
EventLoop["event_loop.rs&lt;br/&gt;- MPV Handle&lt;br/&gt;- command_rx&lt;br/&gt;- Event Emitter"]
TauriEmitter["TauriEventEmitter"]
end
MpvBackend --&gt;|"MpvCommand"| EventLoop
MpvBackend &lt;--&gt;|"Arc&lt;Mutex&lt;&gt;&gt;"| EventLoop
EventLoop --&gt;|"Events"| TauriEmitter
TauriEmitter --&gt; 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&lt;f64&gt;,
pub volume: f32,
pub is_loaded: bool,
pub current_media: Option&lt;MediaItem&gt;,
}
<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&lt;br/&gt;- player_ref&lt;br/&gt;- shared_state"]
NativeFuncs["JNI Callbacks&lt;br/&gt;nativeOnPosition...&lt;br/&gt;nativeOnState...&lt;br/&gt;nativeOnMediaLoaded&lt;br/&gt;nativeOnPlaybackEnd"]
TauriEmitter2["TauriEventEmitter"]
end
subgraph KotlinJVM["Kotlin (JVM)"]
JellyTauPlayer["JellyTauPlayer&lt;br/&gt;- ExoPlayer&lt;br/&gt;- Player.Listener"]
end
ExoBackend --&gt;|"JNI Calls"| JellyTauPlayer
JellyTauPlayer --&gt;|"Callbacks"| NativeFuncs
NativeFuncs --&gt; TauriEmitter2
TauriEmitter2 --&gt; 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(&amp;mut self, settings: &amp;AudioSettings) -&gt; Result&lt;(), PlayerError&gt; {
let json = audio_settings_jni_payload(settings)?;
env.call_method(&amp;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="licensed-audio-codecs-the-ffmpeg-extension"><a class="header" href="#licensed-audio-codecs-the-ffmpeg-extension">Licensed audio codecs: the FFmpeg extension</a></h3>
<p><strong>TRACES</strong>: UR-004, UR-071 | DR-293</p>
<p>Android does not ship AC-3, E-AC-3, DTS or TrueHD decoders — they are licensed
codecs, present only where a vendor paid for them. The ROD2-W09 test tablet has a
vendor DTS decoder and no AC-3/E-AC-3 at all. ExoPlayer has no decoders of its
own, so on such a device those tracks are undecodable, and before this every film
with Dolby audio was re-encoded by the server — for streaming <em>and</em> for download.</p>
<p><code>JellyTauPlayer</code> builds ExoPlayer with <code>DefaultRenderersFactory</code> in
<code>EXTENSION_RENDERER_MODE_ON</code>: the platform's decoders are tried first (a vendor DTS
decoder stays in charge where there is one) and the FFmpeg audio renderer takes
what they cannot decode. <code>CodecDetector</code> reports the extension's codecs beside the
<code>MediaCodecList</code> ones, asking <code>FfmpegLibrary.supportsFormat</code> per MIME type rather
than assuming, so a build whose native library failed to load reports only what
the platform decodes. Rust's device profile and download policy read that list,
which is what keeps "what we tell the server" and "what actually decodes" in step.</p>
<p>The decoder is <code>org.jellyfin.media3:media3-ffmpeg-decoder</code> — Jellyfin's build of
media3's FFmpeg extension, versioned <code>&lt;media3 version&gt;+N</code>. <strong>Bump it in the same
commit as media3.</strong> It is GPL-3.0: the distributed APK carries those terms, the
source stays MIT (see <code>THIRD_PARTY_NOTICES.md</code>). Its JNI methods are covered by the
AAR's own consumer rules and by <code>-keep class androidx.media3.** { *; }</code> in
<code>proguard-jellytau.pro</code>, which also keeps the renderer ExoPlayer loads reflectively.</p>
<p><strong>Rejected:</strong> re-encoding a download's audio on the device after it lands (a
remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
state of its own, and does nothing for streaming. Decoding at playback fixes both
paths with no extra step.</p>
<h3 id="why-the-webview-could-not-stay-a-fallback-on-android"><a class="header" href="#why-the-webview-could-not-stay-a-fallback-on-android">Why the webview could not stay a fallback on Android</a></h3>
<p>The webview decodes none of the codecs above — so with the original file now
downloaded as-is (DR-293), the old <code>experimentalNativeVideo</code> switch would have
played every such download as a silent film. That is what first removed the
webview video path on Android; DR-235 then removed it everywhere.</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>Nothing in the WebView keeps playing once the app is backgrounded — the
system throttles it.</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--&gt;&gt;FE: jellytau-background (DOM CustomEvent)
FE-&gt;&gt;Rust: enter(item, position, audioStreamIndex)
Rust-&gt;&gt;Exo: play audio-only at position
Note over Exo: lockscreen + notification, existing MediaSession
App--&gt;&gt;FE: jellytau-foreground
FE-&gt;&gt;Rust: exit() -&gt; final position
Rust--&gt;&gt;FE: position
FE-&gt;&gt;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> — the position on the item's timeline the player
reports, never an offset within a re-opened transcode (<code>computeHandoffPosition</code>).</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 re-issue the load</strong> (DR-196). The native player owns no
element and nothing watches the stream URL for it, so reassigning the URL — how
the deleted webview path came back — restarted nothing 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. It is the only video
path (DR-235). Before that it was an opt-in whose default was flipped and
reverted twice, each revert with 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)&lt;br/&gt;ExoPlayer video"]
WebView["Tauri WebView (above)&lt;br/&gt;transparent, Svelte controls"]
end
Rust["ExoPlayerBackend"] --&gt;|JNI| Player["JellyTauPlayer"]
Player --&gt; Texture
MainActivity --&gt;|"setTransparent(true)"| WebView
VideoOverlayManager --&gt;|"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 lifts without a <code>&lt;video&gt;</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 &amp; 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&lt;br/&gt;- Lockscreen controls&lt;br/&gt;- Media notifications&lt;br/&gt;- Play/Pause/Next/Previous"]
MediaSessionCompat["MediaSessionCompat&lt;br/&gt;- Remote volume control&lt;br/&gt;- Hardware button interception"]
VolumeProvider["VolumeProviderCompat&lt;br/&gt;- onSetVolumeTo()&lt;br/&gt;- onAdjustVolume()"]
MediaSessionCompat --&gt; VolumeProvider
end
subgraph Hardware["System"]
VolumeButtons["Hardware Volume Buttons"]
Lockscreen["Lockscreen Controls"]
Notification["Media Notification"]
end
subgraph Rust["Rust Backend"]
JNI["JNI Callbacks&lt;br/&gt;nativeOnRemoteVolumeChange()"]
PlaybackMode["PlaybackModeManager&lt;br/&gt;send_remote_volume_command()"]
JellyfinAPI["Jellyfin API&lt;br/&gt;session_set_volume()"]
end
VolumeButtons --&gt; VolumeProvider
Lockscreen --&gt; MediaSession
Notification --&gt; MediaSession
VolumeProvider --&gt; JNI
JNI --&gt; PlaybackMode
PlaybackMode --&gt; 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-&gt;&gt;VolumeButton: Press Volume Up
VolumeButton-&gt;&gt;VolumeProvider: onAdjustVolume(ADJUST_RAISE)
VolumeProvider-&gt;&gt;VolumeProvider: remoteVolumeLevel += 2
VolumeProvider-&gt;&gt;VolumeProvider: currentVolume = remoteVolumeLevel
VolumeProvider-&gt;&gt;JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
JNI-&gt;&gt;PlaybackMode: send_remote_volume_command("VolumeUp", level)
PlaybackMode-&gt;&gt;Jellyfin: POST /Sessions/{id}/Command/VolumeUp
Jellyfin-&gt;&gt;RemoteSession: Set volume to new level
RemoteSession--&gt;&gt;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 -&gt; {
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
currentVolume = remoteVolumeLevel
}
AudioManager.ADJUST_LOWER -&gt; {
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) -&gt; Result&lt;(), String&gt; {
start_playback_service()?;
let service_instance = get_playback_service_instance()?;
env.call_method(&amp;service_instance, "enableRemoteVolume", "(I)V",
&amp;[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()&lt;br/&gt;- Store artworkUrl&lt;br/&gt;- Launch async download"]
AsyncDownload["Coroutine&lt;br/&gt;- Non-blocking&lt;br/&gt;- Dispatchers.IO"]
end
subgraph Cache["AlbumArtCache.kt"]
MemoryCache["LruCache&lt;String, Bitmap&gt;&lt;br/&gt;- 1/8 of heap&lt;br/&gt;- ~12-16MB typical&lt;br/&gt;- 50-100 albums capacity"]
Download["Download &amp; Scale&lt;br/&gt;- 512x512 max&lt;br/&gt;- Exponential backoff"]
ErrorHandle["Error Handling&lt;br/&gt;- Graceful fallback&lt;br/&gt;- Auto-retry"]
end
subgraph Service["JellyTauPlaybackService.kt"]
UpdateMeta["updateMediaMetadata()&lt;br/&gt;- Accept Bitmap parameter&lt;br/&gt;- Add METADATA_KEY_ALBUM_ART"]
Notification["Notification&lt;br/&gt;- setLargeIcon()&lt;br/&gt;- Lock screen display"]
end
LoadMedia --&gt; AsyncDownload
AsyncDownload --&gt; MemoryCache
MemoryCache --&gt; Download
Download --&gt; ErrorHandle
AsyncDownload --&gt; UpdateMeta
UpdateMeta --&gt; Notification
</code></pre>
<p><strong>AlbumArtCache Singleton:</strong></p>
<pre><code class="language-kotlin">class AlbumArtCache(context: Context) {
private val memoryCache = object : LruCache&lt;String, Bitmap&gt;(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) -&gt; Box&lt;dyn PlayerBackend&gt; {
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
#[cfg(target_os = "linux")]
{
match MpvBackend::new(event_emitter.clone()) {
Ok(backend) =&gt; return Box::new(backend),
Err(e) =&gt; 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>