Files
jellytau/specs/linux-native-video-spike.html
T

683 lines
52 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>Linux Native Video Spike - 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 "><a href="../architecture/05-platform-backends.html"><strong aria-hidden="true">5.5.</strong> Platform Backends</a></li><li class="chapter-item "><a href="../architecture/06-downloads-and-offline.html"><strong aria-hidden="true">5.6.</strong> Downloads & Offline</a></li><li class="chapter-item "><a href="../architecture/07-connectivity.html"><strong aria-hidden="true">5.7.</strong> Connectivity</a></li><li class="chapter-item "><a href="../architecture/08-database-design.html"><strong aria-hidden="true">5.8.</strong> Database Design</a></li><li class="chapter-item "><a href="../architecture/09-security.html"><strong aria-hidden="true">5.9.</strong> Security</a></li></ol></li><li class="chapter-item expanded "><li class="part-title">UX</li><li class="chapter-item expanded "><a href="../ux-flows.html"><strong aria-hidden="true">6.</strong> UX Flows</a></li><li class="chapter-item expanded affix "><li class="part-title">Specs — Pending Work</li><li class="chapter-item expanded "><a href="../specs/index.html"><strong aria-hidden="true">7.</strong> Specs Index</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-TEMPLATE.html"><strong aria-hidden="true">8.</strong> Spec Template</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-REVIEW-CHECKLIST.html"><strong aria-hidden="true">9.</strong> Spec Review Checklist</a></li><li class="chapter-item expanded "><a href="../specs/playback-backend-unification.html"><strong aria-hidden="true">10.</strong> Playback Backend Unification</a></li><li class="chapter-item expanded "><a href="../specs/linux-native-video-spike.html" class="active"><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/specs/linux-native-video-spike.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="spec-linux-native-video--bounded-compositing-spike"><a class="header" href="#spec-linux-native-video--bounded-compositing-spike">Spec: Linux native video — bounded compositing spike</a></h1>
<p><strong>Status:</strong> <strong>Run 2026-08-21 — compositing works; G5 carries an open crash.</strong>
The compositing claim it set out to test is falsified on Linux. See "Result".
This file stays open until the implementation spec exists. <strong>ABR is resolved</strong>
the playlist carries one <code>EXT-X-STREAM-INF</code>, so finding 3 is false and there is
no adaptation for mpv to lose. The remaining blocker is the unexplained SIGSEGV
under G5, which is a lifetime problem, not a compositing one.
<strong>Requirements:</strong> none allocated. This spike produces a decision record, not
product code — same shape as
<a href="playback-backend-unification.html">playback-backend-unification.md</a>, which is
Accepted with no requirement ids of its own. Ids are allocated by the
<em>implementation</em> spec that follows a green result.
<strong>UX spec:</strong> n/a
<strong>Supersedes / revises:</strong> re-opens finding 2 of
<a href="playback-backend-unification.html">playback-backend-unification.md</a> on Linux only.
Its findings 3, 4, 5 and 6 stand unchallenged and are <strong>not</strong> in scope here.</p>
<p><strong>Destination on completion:</strong>
<a href="../architecture/05-platform-backends.html">05-platform-backends.md</a> — a "Native
Video Compositing (Linux)" section alongside the existing Android one. The
durable half is the mechanism and the two traps below; the gates and phases are
disposable.</p>
<h2 id="summary"><a class="header" href="#summary">Summary</a></h2>
<p>Test one falsifiable claim: <em>a native video surface cannot be composited with a
Tauri webview on Linux.</em> The claim is load-bearing — it is why Linux video goes
through an h264 HLS transcode into a WebKitGTK <code>&lt;video&gt;</code> element instead of
decoding directly in the mpv instance we already run. The spike renders one mpv
frame beneath the webview, on both X11 and Wayland, and stops. It ships no
product code and flips no defaults.</p>
<p>A green result does <strong>not</strong> authorise native video on Linux; it authorises
writing the spec that would.</p>
<h2 id="motivation"><a class="header" href="#motivation">Motivation</a></h2>
<p><a href="playback-backend-unification.html">playback-backend-unification.md</a> finding 2
concluded that native video cannot be composited with a Tauri webview, on
evidence from <code>tauri-plugin-libmpv</code>'s platform table, wry#284, tauri#6343, and a
Tauri maintainer's 2024 statement that a GTK widget as a child X11 window is
"a bit hacky and it is not possible on Wayland at all."</p>
<p>Two things have changed since that was written, and one thing was never tested.</p>
<p><strong>1. The general claim has already been falsified on one platform — by us.</strong>
Android now renders ExoPlayer video on a TextureView at index 0 <em>behind a
transparent Tauri WebView</em>, with the Svelte controls drawn over it, on by
default. See
<a href="../architecture/05-platform-backends.html#native-video-compositing-android">05-platform-backends.md</a>.
That is exactly the composition finding 2 said was impossible, shipped. What
survives of the finding is a narrower, WebKitGTK-specific claim — which is worth
testing on its own terms rather than inheriting.</p>
<p><strong>2. A Tauri app now ships Linux native mpv as an active platform.</strong>
<a href="https://github.com/MaxMB15/MaxVideoPlayer">MaxVideoPlayer</a> (354 commits) embeds
libmpv via <strong>EGL + X11 child window / Wayland subsurface</strong>, with Linux and macOS
active and Windows only planned — the inverse of the plugin matrix finding 2
sampled. Its existence does not prove our case works, but it does mean the
Wayland half of the maintainer quote is out of date.</p>
<p><strong>3. The render API was never tested.</strong> Every source in finding 2 describes
<em>foreign-window embedding</em>: <code>--wid</code>, child windows, a second toplevel
position-synced to a <code>getBoundingClientRect()</code> div. That is a different mechanism
from mpv's render API, where <strong>we</strong> own the GL context and mpv draws into an FBO
we hand it (<code>mpv_render_context_create</code> / <code>mpv_render_context_render</code>, with an
upstream <a href="https://github.com/mpv-player/mpv-examples/pull/44/files">GTK example</a>).
Tauri v2 exposes <code>WebviewWindow::gtk_window()</code> and <code>default_vbox()</code>, so the
target is a widget inside Tauri's own GTK tree — not a foreign window, not a
second toplevel, and therefore not the thing that was found broken.</p>
<p>The prize is direct play: no h264 transcode, hardware decode, libass subtitles,
and no server CPU burned on every Linux play.</p>
<h2 id="the-blocker-a-green-spike-does-not-clear"><a class="header" href="#the-blocker-a-green-spike-does-not-clear">The blocker a green spike does not clear</a></h2>
<p>🔴 <strong>Read this before treating a green result as a green light.</strong></p>
<p>Finding 3 of the unification spec stands: <strong>mpv has no adaptive bitrate.</strong> It
delegates HLS to FFmpeg's demuxer, which picks one variant at open and never
adapts. The webview path has real ABR via hls.js. Compositing is necessary for
native video on Linux; it is not sufficient.</p>
<p>There is a plausible answer, and this spike exists partly to make it testable:
<strong>ABR only matters on the transcode path.</strong> A direct-played file has no variant
ladder to adapt between — the adaptation the server offers <em>is</em> the transcode.
So "mpv when the stream is direct-play, HTML5 + hls.js when the server
transcodes" would sidestep finding 3 rather than fight it, and it maps onto a
decision Rust already makes when it builds the stream URL.</p>
<p>That is a <strong>hypothesis, not a conclusion.</strong> It is out of scope here. Record it in
the spike's decision note so the follow-up spec starts from it.</p>
<h2 id="layer-assignment"><a class="header" href="#layer-assignment">Layer assignment</a></h2>
<p>The spike introduces no product logic. The table below is the assignment the
<em>follow-up</em> would inherit, written now so a green result cannot drift into
frontend decisions during implementation.</p>
<div class="table-wrapper"><table><thead><tr><th>Logic / responsibility</th><th>Layer</th><th>Why it belongs there</th></tr></thead><tbody>
<tr><td>Which backend renders video on this platform (<code>use_html5_element</code>, <code>supports_native_video</code>)</td><td>Rust</td><td>Already there — <code>get_player_status</code> in <code>commands/player/mod.rs</code> computes it from a <code>cfg!</code>. The spike would widen that <code>cfg!</code>, not relocate the decision. The frontend already consumes it via <code>createAdapter</code>.</td></tr>
<tr><td>Whether <em>this stream</em> is direct-play or transcoded, and therefore whether mpv or hls.js renders it</td><td>Rust</td><td>Domain. It depends on Jellyfin's <code>PlaybackInfo</code> response, container/codec support, and the bitrate cap — all of which change when Jellyfin's API or our quality ladder changes. The frontend must never re-derive it from a URL shape.</td></tr>
<tr><td>Creating, sizing, and destroying the GL surface; the mpv render context</td><td>Rust</td><td>Owns the backend and the GTK window handle. There is no presentation decision in it.</td></tr>
<tr><td>Where controls, subtitles, and the mini-player sit above the video, and the letterbox/poster treatment</td><td>Frontend</td><td>Pure presentation; changes only if the UI is redesigned. Precisely the split the Android path already uses.</td></tr>
<tr><td>Reserving the video rectangle in layout and marking the shell transparent</td><td>Frontend</td><td>Presentation. <code>nativeVideo.ts</code> + the <code>[data-native-video="active"]</code> rule in <code>app.css</code> already do this for Android and are platform-agnostic.</td></tr>
</tbody></table>
</div>
<p>Borderline row, stated with its tie-breaker: <em>"is the surface currently
attached?"</em> reads like view state, but the Android work found that a surface left
in the hierarchy outlives its player (DR-184). Attachment is backend lifecycle →
<strong>Rust</strong>, with the frontend told about it, not asked.</p>
<h2 id="design"><a class="header" href="#design">Design</a></h2>
<p>A throwaway branch. No merge to <code>master</code> except the decision note.</p>
<h3 id="what-gets-built"><a class="header" href="#what-gets-built">What gets built</a></h3>
<p>One <code>#[cfg(target_os = "linux")]</code> experiment behind a feature flag, in a scratch
binary or an ignored test — <strong>not</strong> in <code>MpvBackend</code>'s constructor path:</p>
<ol>
<li>From <code>app.get_webview_window(...)</code>, take <code>gtk_window()</code> and <code>default_vbox()</code>.</li>
<li>Reparent the webview into a <code>gtk::Overlay</code>: <code>GLArea</code> as the main child, the
webview as the overlay child.</li>
<li>Set the webview background to fully transparent (wry does this when
<code>"transparent": true</code>; verify it reaches <code>webkit_web_view_set_background_color</code>).</li>
<li>In the <code>GLArea</code>'s <code>render</code> signal, drive
<code>mpv_render_context_render</code> with <code>MPV_RENDER_PARAM_OPENGL_FBO</code> pointing at the
FBO GTK bound for us.</li>
<li>Play one local file. Draw an opaque HTML element over the video area.</li>
</ol>
<p><code>video = no</code> and <code>audio-display = no</code> are set in
<a href="../../src-tauri/src/player/mpv_backend.rs#L135-L141">mpv_backend.rs:135-141</a>;
the spike overrides them on its own <code>Mpv</code> handle rather than editing that path.</p>
<h3 id="bindings"><a class="header" href="#bindings">Bindings</a></h3>
<p>The current pin is <code>libmpv = { git = "…/libmpv-rs", branch = "master" }</code> — the
dead pin <a href="libmpv2-migration.html">libmpv2-migration.md</a> exists to replace. The
render API lives in <code>libmpv2-sys</code> (<code>mpv_render_context_render</code>); the safe wrapper
was only ever a PR against the old crate. <strong>Use <code>libmpv2-sys</code> raw FFI directly in
the spike.</strong> Do not block the spike on the migration, and do not let the spike
half-perform it — if the spike goes green the migration becomes a hard
prerequisite of the implementation, which is the ordering
<a href="windows-native-audio-backend.html">windows-native-audio-backend.md</a> already sits
in.</p>
<h3 id="ipc"><a class="header" href="#ipc">IPC</a></h3>
<p>None. The spike crosses no boundary. If it goes green, the follow-up changes only
the <em>value</em> of the existing <code>useHtml5Element</code> / <code>supportsNativeVideo</code> fields — no
new wire shapes, no <code>bindings.ts</code> regeneration.</p>
<h2 id="gates"><a class="header" href="#gates">Gates</a></h2>
<p>Each is pass/fail with a named failure. Stop at the first red and write it up —
a red result is a successful spike.</p>
<div class="table-wrapper"><table><thead><tr><th>#</th><th>Question</th><th>Fails if</th></tr></thead><tbody>
<tr><td>G1</td><td>Can a custom GTK widget join Tauri's widget tree and survive the window's lifetime?</td><td><code>default_vbox()</code> is absent/unusable, or reparenting the webview breaks input or crashes.</td></tr>
<tr><td>G2</td><td>Does the webview still paint, with a transparent backdrop, over that widget?</td><td>The backdrop renders opaque black (<a href="https://github.com/tauri-apps/wry/issues/1540">wry#1540</a>) or the webview stops repainting (<a href="https://github.com/tauri-apps/tauri/issues/12800">tauri#12800</a>). <strong>This is the highest-risk gate.</strong></td></tr>
<tr><td>G3</td><td>Does mpv render a frame into our FBO?</td><td>The render context refuses GTK's context, or frames land in the wrong buffer.</td></tr>
<tr><td>G4</td><td>Does HTML drawn over the video area actually appear over it?</td><td>Video covers the controls — the exact failure wry#284 and tauri#6343 report. Without this, the whole thing is worthless: our controls, subtitles and mini-player all sit over the video.</td></tr>
<tr><td>G5</td><td>Does it survive resize, fullscreen, and SPA navigation away and back?</td><td>Flicker on resize, or a surface that outlives its route.</td></tr>
<tr><td>G6</td><td>Does it hold on <strong>both</strong> X11 and Wayland?</td><td>Either session backend fails. Wayland is the one the 2024 maintainer quote says is impossible — test it first, not last.</td></tr>
</tbody></table>
</div>
<p>G6 is not a nice-to-have. A result that only holds on X11 is red for a project
shipping to current desktops.</p>
<h3 id="time-box"><a class="header" href="#time-box">Time box</a></h3>
<p>If G1G4 are not all green, stop and write the result up. The value of this spike
is a dated, method-specific answer — including "still no, and here is the
mechanism" — not a working player.</p>
<h2 id="result-2026-08-21"><a class="header" href="#result-2026-08-21">Result (2026-08-21)</a></h2>
<p>Run on GNOME, kernel 7.1.8, libmpv 2.5.0 (mpv 0.41.0), GTK 3.24.52, WebKitGTK
2.52.6, wry 0.53.5 — the versions <code>src-tauri/Cargo.lock</code> resolves. Spike source:
a ~250-line standalone crate using wry + gtk + <code>libmpv2-sys</code> raw FFI, driving
mpv's render API with an update callback, frame-gated repaints and
<code>report_swap</code>.</p>
<div class="table-wrapper"><table><thead><tr><th>Gate</th><th>Result</th><th>Observed mechanism</th></tr></thead><tbody>
<tr><td>G1 widget in GTK tree</td><td>🟡 <strong>partial</strong></td><td><code>GtkOverlay</code> with <code>GtkGLArea</code> as main child and the wry webview as overlay child works, built directly. <strong>Tauri's own <code>default_vbox()</code> was not exercised</strong> — see below.</td></tr>
<tr><td>G2 webview paints transparently over it</td><td>✅ green</td><td><code>with_transparent(true)</code> alone. No window-level transparency was used or needed.</td></tr>
<tr><td>G3 mpv renders into our FBO</td><td>✅ green</td><td><code>vo=libmpv</code> + <code>mpv_render_context_create</code> with <code>MPV_RENDER_PARAM_OPENGL_FBO</code> into the FBO GTK binds.</td></tr>
<tr><td>G4 HTML over video</td><td>✅ green</td><td>Opaque panel and a translucent control bar both drew over moving video.</td></tr>
<tr><td>G5 resize / drag / fullscreen</td><td>🟡 <strong>green on appearance, suspect underneath</strong></td><td>No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open".</td></tr>
<tr><td>G6 X11 <strong>and</strong> Wayland</td><td>✅ green</td><td>Identical on both; <code>GDK_BACKEND</code> flipped between runs.</td></tr>
</tbody></table>
</div>
<p><strong>Finding 2 of <a href="playback-backend-unification.html">playback-backend-unification.md</a>
is false on Linux</strong> when tested by the render API rather than by foreign-window
embedding. Wayland — the half the 2024 maintainer quote called impossible — is
green.</p>
<p>Better than the gate asked for: the translucent bar composited <em>alpha</em> against
the video, not merely opaque-over. Scrims, gradient fades and subtitle backdrops
therefore work, which is most of how a player UI actually looks. mpv also painted
the letterbox bars black on its own — the Android equivalent was a shipped defect
(DR-194).</p>
<h3 id="three-traps-each-of-which-cost-a-debugging-cycle"><a class="header" href="#three-traps-each-of-which-cost-a-debugging-cycle">Three traps, each of which cost a debugging cycle</a></h3>
<p>Carry these into the implementation; each produced a failure that looked like a
platform limitation and was not.</p>
<ol>
<li><strong><code>LC_NUMERIC</code> must be reset <em>after</em> <code>gtk::init()</code>, not before.</strong> mpv refuses
to start under a non-C numeric locale. <code>mpv_backend.rs</code> already handles this,
but it has no GTK init in front of it; on this path <code>gtk::init()</code> applies the
user's locale afterwards and <code>mpv_create</code> returns null.</li>
<li><strong>libepoxy exports GL entry points as <em>data</em> symbols.</strong> There is no <code>glFoo</code>
function to resolve — there is <code>epoxy_glFoo</code>, a variable holding a lazily
resolving function pointer. <code>get_proc_address</code> must return the pointer <em>stored
at</em> that symbol; returning the symbol's own address makes mpv jump into
non-executable data and take SIGSEGV/SEGV_ACCERR on the first GL call. The
<code>epoxy</code> crate does this correctly but is unusable — its <code>gl_generator</code>
dependency pulls a yanked <code>xml-rs</code>.</li>
<li><strong>Frame pacing is not optional, and its symptom is misleading.</strong> Driving
<code>queue_render()</code> off the widget's frame clock on every tick, without calling
<code>mpv_render_context_report_swap</code> after each render, leaves mpv with nothing to
time against. Playback looks fine in a window and <strong>judders at fullscreen</strong>
which reads as a compositing or GPU limit and is neither. The fix is to
register <code>mpv_render_context_set_update_callback</code>, redraw only when it says a
frame is ready, and report the swap afterwards. Fullscreen was smooth
immediately once both were in place.</li>
</ol>
<h3 id="hardware-decode-through-the-render-api"><a class="header" href="#hardware-decode-through-the-render-api">Hardware decode through the render API</a></h3>
<p>Tested by asking mpv what it actually selected (<code>hwdec-current</code>), not what it was
asked for. All three ran 20s clean at a steady 30 fps.</p>
<div class="table-wrapper"><table><thead><tr><th><code>hwdec</code></th><th><code>hwdec-current</code></th><th>Note</th></tr></thead><tbody>
<tr><td><code>vaapi</code></td><td><code>no</code></td><td><strong>Did not engage</strong> on this box — silently fell back to software. <code>vainfo</code> is not installed, so the libva driver for the Iris Xe iGPU is likely absent. No render-API error; this looks like a missing driver package, not a compositing limit.</td></tr>
<tr><td><code>auto</code></td><td><code>nvdec-copy</code></td><td>Hardware decode <strong>does</strong> work through the render API, on the discrete RTX 3050. Copy-back rather than zero-copy interop.</td></tr>
<tr><td><code>no</code></td><td><code>no</code></td><td>Software. Clean baseline.</td></tr>
</tbody></table>
</div>
<p>The load-bearing result is the middle row: <strong>hardware decode is compatible with
mpv's render API</strong>, so the direct-play prize is real and not traded away for
software decoding. Which decoder to prefer is an implementation question — on a
hybrid Intel+NVIDIA laptop <code>auto</code> reached for the discrete GPU in copy-back mode,
which is the least efficient hardware path. An implementation should evaluate
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
accepting <code>auto</code>.</p>
<p><code>hwdec=auto-safe</code> probes Vulkan video decode, which this GPU does not support.
It logs two <code>Failed setup for format vulkan</code> / <code>no frame!</code> pairs at start-up and
then settles on <code>nvdec-copy</code> — the same place <code>auto</code> lands. A first reading of
these logs mistook the start-up pair for a per-frame flood; <strong>it is not</strong>. Every
run, clean or crashed, contains exactly two. <code>auto-safe</code> is not implicated in
anything.</p>
<h3 id="what-is-still-open"><a class="header" href="#what-is-still-open">What is still open</a></h3>
<ul>
<li>
<p><strong>The Tauri half of G1.</strong> The spike built its own <code>GtkOverlay</code>. The app must
instead reach <code>WebviewWindow::gtk_window()</code> / <code>default_vbox()</code> and reparent
Tauri's existing webview into an overlay. Low risk — the same widgets, one
extra reparent — but unproven, and it is the only place Tauri-specific
behaviour could still bite.</p>
</li>
<li>
<p><strong>ABR — resolved. Finding 3's premise is false.</strong> Finding 3 said mpv would
regress streaming quality because "the webview path already has real ABR via
hls.js". Three pieces of evidence in this repo suggested that is <strong>not true of
the URLs we actually build</strong>:</p>
<ol>
<li><code>get_video_stream_url</code> (<code>repository/online.rs</code>) requests a <em>single</em>
rendition — one <code>VideoBitrate</code>, one <code>MaxStreamingBitrate</code>, one <code>MaxHeight</code>.
Jellyfin transcodes to what it is asked for; it does not build a ladder.</li>
<li>The frontend contains <strong>no level-handling code at all</strong> — no <code>hls.levels</code>,
no <code>LEVEL_SWITCH</code>, no <code>currentLevel</code>. The <code>abrEwma*</code> options in
<code>VideoPlayer.svelte</code> are default tuning with nothing to act on. hls.js is
serving as an HLS <em>demuxer</em> (WebKitGTK cannot play HLS natively), not as an
adaptation engine.</li>
<li>That function's own comment describes a quality switch as <strong>rebuilding the
URL</strong> — "every path that re-opens a stream (quality switch, transcoded seek,
audio-track switch)". Manual selection by stream re-open is what you build
when there is no adaptation, and mpv can do the same thing.</li>
</ol>
<p><strong>The decisive test has now been run</strong> (2026-08-21, against the development
server, Jellyfin 10.11.5):</p>
<pre><code>curl -s ".../Videos/&lt;itemId&gt;/master.m3u8?…&amp;TranscodingProtocol=hls&amp;…" \
| grep -c EXT-X-STREAM-INF
1
</code></pre>
<p><strong>One line.</strong> The playlist carries a single <code>EXT-X-STREAM-INF</code> plus an
<code>EXT-X-IMAGE-STREAM-INF</code> trickplay entry, which is not a rendition. Jellyfin
builds the master playlist from the rendition the request asked for; it does
not publish a ladder. So <strong>there is no ABR to lose, and this blocker is
closed</strong> — hls.js is serving as an HLS demuxer, exactly as (2) above supposed,
and mpv gives up nothing by replacing it.</p>
<p>Recorded as DR-229 (Won't Do) rather than deleted, because it is a
measurement: a server that <em>does</em> publish a ladder would change the answer, and
the re-negotiation path is the hook that work would build on.</p>
<p><strong>The direct-play path now exists.</strong> It did not when this spike was written —
every video play went through the HLS transcode endpoint. Backend-owned stream
selection (DR-225 … DR-230) built it: Rust negotiates direct play / direct
stream / transcode and hands every backend one <code>StreamSelection</code> carrying the
URL, the transport and the chosen rendition. <strong>That is the contract this
implementation consumes</strong> — mpv is a consumer of a decision already made, not a
place to re-derive it.</p>
<p>It also sizes the prize precisely. Measured over the same server, 40 items
through a real negotiation per profile:</p>
<div class="table-wrapper"><table><thead><tr><th>Profile</th><th>Direct play</th></tr></thead><tbody>
<tr><td>Linux / WebKitGTK — <code>h264</code> only, 2ch</td><td><strong>7%</strong></td></tr>
<tr><td>Android / ExoPlayer — <code>h264,hevc,vp8,vp9,av1,mpeg4</code> + <code>ac3,eac3</code>, 6ch</td><td><strong>85%</strong></td></tr>
</tbody></table>
</div>
<p><strong>The 85% is a ceiling, not a shipped result</strong> — it was measured with a
profile containing <code>ac3,eac3</code>, which the Android device later used for
verification does not support.</p>
<p>The library sampled is ~80% hevc. Linux sits at 7% <strong>solely because the
WebKitGTK profile can only claim h264</strong> — not because of anything about the
server or the negotiation. mpv decodes hevc, so widening the Linux device
profile once mpv renders the picture is what converts that 7% toward the
Android figure. That conversion is the actual product of this work; the
compositing proven above is the mechanism that permits it.</p>
</li>
<li>
<p>🔴 <strong>One unexplained SIGSEGV.</strong> A ~180s
run died in a <em>decoder</em> thread (libavcodec -&gt; <code>av_log</code> -&gt; libmpv's log handler
-&gt; libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
stack, so the fault is on the mpv/ffmpeg side of the process rather than in the
compositing seam.</p>
<p>Three hypotheses were tested and <strong>none reproduced it</strong>:</p>
<div class="table-wrapper"><table><thead><tr><th>Hypothesis</th><th>Test</th><th>Result</th></tr></thead><tbody>
<tr><td><code>hwdec=auto-safe</code>'s Vulkan failures</td><td>300s soak on <code>auto-safe</code></td><td>Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead.</td></tr>
<tr><td>Fullscreen transitions recreating the GL context under mpv's render context</td><td>240s soak, ~120 automated transitions</td><td>Survived, no core dumped.</td></tr>
<tr><td>Continuous resize thrashing the GL framebuffer</td><td>240s soak, ~2000 resizes</td><td>Survived, no core dumped.</td></tr>
</tbody></table>
</div>
<p><strong>The crash is therefore unexplained.</strong> It was observed exactly once, in the
only session a human interacted with, and did not recur in ~13 minutes of
targeted stress across the three most plausible causes. It is recorded here
rather than dismissed precisely because nothing explains it: an intermittent
fault that nobody can reproduce is worse to inherit than a deterministic one,
not better.</p>
<p>The underlying concern stands regardless of which test eventually reproduces
it. A SIGSEGV in an unrelated thread is characteristic of memory corruption,
and this spike never calls <code>mpv_render_context_free</code> and never tears down on
<code>unrealize</code> — it has no defence against the GL context being recreated beneath
the render context. That is DR-184 on Android restated: a surface outliving its
player. An implementation must bind the two lifetimes together whether or not
this particular crash is ever explained.</p>
<p><strong>Therefore G5 is recorded green on appearance only</strong>, and this crash is the
single largest piece of unfinished business in the spike. Do not read the green
gates above as "safe to build on" until it is explained or a long soak clears
it.</p>
</li>
<li>
<p>Long-run stability, seeking, track switching, HDR, and multi-window were not
exercised at all.</p>
</li>
</ul>
<h2 id="out-of-scope"><a class="header" href="#out-of-scope">Out of scope</a></h2>
<ul>
<li>Any change to the shipping Linux video path. <code>experimentalNativeVideo</code> in
<code>adapters/index.ts</code> is a <strong>suppressor, never a promoter</strong>; the spike must not
change that.</li>
<li>Adaptive bitrate. See "The blocker a green spike does not clear".</li>
<li>Windows and macOS — different mechanisms, and <strong>Windows is the easier case, not
the endangered one</strong>. See below.</li>
<li>Android. Already shipped; it is the precedent, not the target.</li>
<li>Crossfade, the libmpv2 migration, and the audio-parity work.</li>
</ul>
<h3 id="why-windows-is-unaffected-and-cheaper"><a class="header" href="#why-windows-is-unaffected-and-cheaper">Why Windows is unaffected, and cheaper</a></h3>
<p>Nothing here can regress Windows. <code>use_html5_element</code> is already a per-platform
<code>cfg!</code> in <code>get_player_status</code> — Android native, everything else HTML5 — so
divergent video paths are the existing design rather than something this
introduces. Windows keeps <code>&lt;video&gt;</code> + hls.js whatever this spike returns.</p>
<p>The mechanism does not port: <code>default_vbox()</code>, <code>GtkOverlay</code> and <code>GtkGLArea</code> are
GTK3/WebKitGTK concepts. But the <em>question</em> is already answered more favourably
there. Both mpv plugins list Windows as <strong>fully tested</strong> and Linux as broken,
because WebView2 honours a transparent background — the "native surface beneath a
transparent webview" approach that fails on WebKitGTK is the one that works on
Windows. That asymmetry is why
<a href="windows-native-audio-backend.html">windows-native-audio-backend.md</a> can call
Windows "the cleanest available win".</p>
<p>Windows' cost is packaging, not compositing: the build cross-compiles with MSVC +
<code>cargo-xwin</code>, so libmpv arrives as a bundled prebuilt DLL (the ⚠️ in finding 5's
comparison table). That cost is already committed for <em>audio</em>. Once the DLL ships
to replace <code>WebviewAudioBackend</code>, Windows video is largely a follow-on.</p>
<p>Sequencing, if native video is ever pursued on both:</p>
<ol>
<li><a href="libmpv2-migration.html">libmpv2-migration.md</a> — prerequisite for either.</li>
<li><a href="windows-native-audio-backend.html">windows-native-audio-backend.md</a> — already
specced; lands the DLL and a real Windows backend.</li>
<li>Windows native video — cheap once 2 exists, and does not need this spike.</li>
<li>Linux native video — needs this spike, and runs independently of 13.</li>
</ol>
<h3 id="does-this-add-a-backend"><a class="header" href="#does-this-add-a-backend">Does this add a backend?</a></h3>
<p>No — and the trajectory is convergence, not proliferation.</p>
<p><code>create_player_backend</code> in <code>lib.rs</code> already selects between four
<code>PlayerBackend</code> impls by <code>cfg!</code>: <code>MpvBackend</code> (Linux), <code>ExoPlayerBackend</code>
(Android), <code>WebviewAudioBackend</code> (Windows and anything else), and <code>NullBackend</code>
as the graceful-init fallback. The HTML5 video path is not among them — it is a
frontend adapter reporting through <code>player_report_*</code>, not a <code>PlayerBackend</code>.</p>
<p>This spike adds none of these. <code>MpvBackend</code> already exists and already runs on
Linux; it merely sets <code>video = no</code> at construction. Giving it video widens an
existing backend rather than introducing an engine.</p>
<p>Following the sequence above, the count goes <strong>down</strong>: replacing
<code>WebviewAudioBackend</code> with mpv on Windows leaves two native engines — mpv
(Linux + Windows) and ExoPlayer (Android) — with native video riding on both.</p>
<p>Two is the floor, for a reason worth stating so nobody re-litigates it: Android
cannot drop ExoPlayer even if libmpv runs there, because the foreground service,
<code>MediaSessionCompat</code> and lockscreen control are built on it (finding 7 puts the
cost at that rewrite, not at the bindings). The HTML5 path does not go away
either — it is the transcode/ABR route and the fallback.</p>
<p>The trait surface converges too: <code>ExoPlayerBackend</code> already implements the
video-surface lifecycle for Android native compositing, so teaching <code>MpvBackend</code>
video follows a path already walked rather than opening a second one.</p>
<ul>
<li>Adopting <code>tauri-plugin-libmpv</code> or <code>tauri-plugin-mpv</code> as dependencies. Both
report Linux window embedding as not working and are small projects
(20 and ~70 commits); read them, do not depend on them.</li>
</ul>
<h2 id="acceptance-criteria"><a class="header" href="#acceptance-criteria">Acceptance criteria</a></h2>
<p>The deliverable is a decision, not a feature.</p>
<ul>
<li><input disabled="" type="checkbox"/>
Each of G1G6 recorded green/red <strong>with the observed mechanism</strong>, not just
the verdict.</li>
<li><input disabled="" type="checkbox"/>
X11 and Wayland results reported separately, each naming the compositor
and WebKitGTK version tested.</li>
<li><input disabled="" type="checkbox"/>
The direct-play/transcode ABR hypothesis recorded as open, with whatever
the spike learned about it.</li>
<li><input disabled="" type="checkbox"/>
<code>docs/specs/README.md</code> updated — this spec listed, and its row moved or
deleted per the result.</li>
<li><input disabled="" type="checkbox"/>
The Linux claim in the <code>createAdapter</code> doc comment
(<a href="../../src/lib/player/adapters/index.ts#L12-L13">adapters/index.ts:12-13</a>)
corrected either way: if red, cite this spike instead of asserting it; if
green, it is wrong and must be rewritten.</li>
<li><input disabled="" type="checkbox"/>
On <strong>red</strong>: finding 2 of
<a href="playback-backend-unification.html">playback-backend-unification.md</a> gains a
dated note naming the render-API method as also tested, and this file is
deleted. The verdict lives in the design-authority spec, not in a second
file that contradicts nothing.</li>
<li><input disabled="" type="checkbox"/>
On <strong>green</strong>: an implementation spec exists, allocating ids from
<strong>UR-077 / IR-033 / DR-216</strong> (re-check <code>requirements.md</code> — the README's
"next free DR-215" is stale, DR-215 landed), and it must answer ABR before
being accepted.</li>
<li><input disabled="" type="checkbox"/>
No spike code on <code>master</code>. If any lands, the standard gates apply:
<code>bun run check</code>, <code>bun run test</code>, <code>bun run check:boundary</code>, <code>cargo fmt</code>,
<code>cargo clippy</code>, <code>bun run test:rust</code>.</li>
</ul>
<h2 id="testing"><a class="header" href="#testing">Testing</a></h2>
<p>No automated tests. A compositing result is a visual, per-session-backend
observation and cannot be asserted in <code>cargo test</code> or vitest — pretending
otherwise would produce a test that passes on a headless runner and tells us
nothing.</p>
<p>Capture a screenshot per gate. G4 specifically: an opaque HTML element over the
video area, photographed showing the video <em>behind</em> it.</p>
<p>If it goes green, the implementation spec inherits the testable surface the
Android work already established — <code>nativeVideoLayers.test.ts</code> asserts the
<code>app.css</code> selector list and the <code>data-native-video</code> contract, and both are
platform-agnostic.</p>
<h2 id="traces"><a class="header" href="#traces">TRACES</a></h2>
<p>None. No requirement-implementing code is produced. The implementation spec that
follows a green result allocates from DR-216 and tags there.</p>
<h2 id="notes-for-the-implementer"><a class="header" href="#notes-for-the-implementer">Notes for the implementer</a></h2>
<ul>
<li><strong>Read <a href="playback-backend-unification.html">playback-backend-unification.md</a>
first, in full.</strong> This spike disputes exactly one of its six findings, on one
platform, by one method it did not try. Everything else in it is still binding
— particularly finding 3.</li>
<li>Test <strong>Wayland first</strong>. It is the gate most likely to be red and the one that
makes the rest moot.</li>
<li>The frontend plumbing already exists from the Android work: <code>createAdapter</code>,
<code>NativePlayerAdapter</code>, <code>nativeVideo.ts</code>, <code>videoSurface.ts</code>, and the
<code>[data-native-video="active"]</code> rule. A green spike is far cheaper to implement
than it would have been a year ago — which is itself part of why the question
is worth re-asking.</li>
<li>The Android record in
<a href="../architecture/05-platform-backends.html#native-video-compositing-android">05-platform-backends.md</a>
lists six shipped defects from getting this right on one platform. Expect the
Linux equivalents (the surface outliving its player, the shell painting over
it, unpainted letterbox bars) rather than rediscovering them.</li>
<li>A parallel Claude session may be active in this repo — <code>git diff</code> before
"repairing" unexpected changes.</li>
</ul>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../specs/playback-backend-unification.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="../specs/backend-owned-stream-selection.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="../specs/playback-backend-unification.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="../specs/backend-owned-stream-selection.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>