Files
jellytau/native-player-verification.html
T

436 lines
29 KiB
HTML

<!DOCTYPE HTML>
<html lang="en" class="navy" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>Native Player Verification - 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"><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" class="active"><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/native-player-verification.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="native-player--verification-plan"><a class="header" href="#native-player--verification-plan">Native player — verification plan</a></h1>
<p>What to check before the <code>MediaPlayer</code> contract and Linux native video reach
<code>master</code>.</p>
<p>This is not a generic smoke test. Every case below exists because something
specific went wrong, and most of them were found on hardware <strong>after</strong> the
automated suites were green. Treat the sequences as load-bearing: several
defects only appeared in a particular order of actions, and testing the same
features in a different order missed them entirely.</p>
<p>Companion to <a href="release-checklist.html">release-checklist.md</a>, which covers the
release mechanics. This covers whether the player is fit to release at all.</p>
<h2 id="what-is-risky-about-this-change"><a class="header" href="#what-is-risky-about-this-change">What is risky about this change</a></h2>
<ul>
<li><code>PlayerController</code> now talks to a <code>MediaPlayer</code> contract instead of
<code>PlayerBackend</code>. Every engine reaches it through an adapter that did not exist
before (DR-245).</li>
<li>mpv decodes video on Linux for the first time, composited under the webview
(DR-231).</li>
<li>Seek strategy is driven by an ability each engine declares rather than by a
truth table (DR-246).</li>
<li>Two regressions were introduced during this work and caught only on a device:
a wrong capability for ExoPlayer (DR-246 follow-up) and a <code>Duration</code> panic
(DR-252). Both were invisible to the test suites.</li>
</ul>
<p>The suites originally verified only engines that <em>behave</em>, which is why both
regressions passed them. That gap is now partly closed in code rather than in
this document: <code>UT-223</code> drives a deliberately hostile engine — <code>C.TIME_UNSET</code>,
NaN, infinities, negatives — through the adapter, and fails with the exact
panic that produced a black screen on a tablet. <code>UT-224</code> pins the handoff
clearing that was previously verified by listening to a device.</p>
<p><strong>Prefer moving cases out of this file and into tests.</strong> Anything here that
could fail automatically should; a checklist depends on someone remembering to
follow it, and the two defects it was written for cost hardware time that would
have been better spent making the suites realistic. What is left below is what
genuinely needs eyes, ears, or a display — not what merely has not been
automated yet.</p>
<h2 id="1-automated-gates"><a class="header" href="#1-automated-gates">1. Automated gates</a></h2>
<p>Cheap, fast, and non-negotiable. Run from the worktree.</p>
<pre><code class="language-bash">bun run check # 0 errors, 0 warnings
bun run test # frontend
bun run test:rust # Rust
bun run format:check
bun run lint # 0 errors; warnings at or below the CI ratchet
bun run check:boundary
bun run traces:validate
bun run traces:coverage # at or above MIN_THRESHOLD
cd src-tauri &amp;&amp; cargo fmt --check &amp;&amp; cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --features conformance -- -D warnings
</code></pre>
<p>The eslint warning count is a <strong>ratchet</strong>: equal to the CI limit is a pass, one
over fails the build. Going one over is how a piece of dead state was found
during this work — do not raise the limit to get past it.</p>
<h2 id="2-engine-conformance"><a class="header" href="#2-engine-conformance">2. Engine conformance</a></h2>
<pre><code class="language-bash">bun run test:player # mpv + legacy, desktop
bun run test:player:android # ExoPlayer, on a connected device
</code></pre>
<p>Expected, and each deviation is meaningful rather than noise:</p>
<div class="table-wrapper"><table><thead><tr><th>Engine</th><th>Result</th><th>If it differs</th></tr></thead><tbody>
<tr><td><code>MpvPlayer</code></td><td>9/9</td><td>A real regression. Stop.</td></tr>
<tr><td><code>LegacyPlayer</code></td><td>8/9</td><td>The one failure is <code>transport_settings_round_trip</code>: the old trait has no mute or rate. Any <em>other</em> failure is a regression.</td></tr>
<tr><td>ExoPlayer (device)</td><td>7/7</td><td>Two cases are absent because the Kotlin player exposes no mute or rate.</td></tr>
</tbody></table>
</div>
<p>A green conformance run is <strong>not</strong> sufficient evidence to ship. Both regressions
introduced during this work passed conformance.</p>
<h2 id="3-desktop-linux"><a class="header" href="#3-desktop-linux">3. Desktop (Linux)</a></h2>
<p>Run with native video on, since that is what is new:</p>
<pre><code class="language-bash">JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
</code></pre>
<ul>
<li><input disabled="" type="checkbox"/>
<strong>Direct play</strong> — a file the server does not transcode. Picture and sound.</li>
<li><input disabled="" type="checkbox"/>
<strong>Transcoded play</strong> — something the server must re-encode (4K, HEVC, or an
audio codec the renderer cannot take).</li>
<li><input disabled="" type="checkbox"/>
<strong>Resume</strong> — an item watched previously <em>on this install</em>. The prompt
appears and playback starts at the offered position, not at zero.
<em>(Resume is device-local — see "Known open".)</em></li>
<li><input disabled="" type="checkbox"/>
<strong>Scrub</strong> on a direct-play item; position lands and playback continues.</li>
<li><input disabled="" type="checkbox"/>
<strong>Scrub on a transcoded item.</strong> Separate case on purpose: it takes a
different path, and it silently did nothing for months (DR-238).</li>
<li><input disabled="" type="checkbox"/>
<strong>Pause and resume</strong> — the button follows the player. It stopped doing so
when a property was handled but never observed (DR-239).</li>
<li><input disabled="" type="checkbox"/>
<strong>Fullscreen</strong> — the window really fills the display. Measure it if
unsure: the log prints <code>rendering WxH</code>, and a height short of the panel
means the document went fullscreen and the window did not (DR-240).</li>
<li><input disabled="" type="checkbox"/>
<strong>Exit the player</strong> — audio stops. Listen; do not assume.</li>
<li><input disabled="" type="checkbox"/>
<strong>Audio-only playback</strong> still works: mini player, queue, next/previous.</li>
<li><input disabled="" type="checkbox"/>
Nothing in the log matches <code>PANIC</code> or <code>ERROR</code>.</li>
</ul>
<h2 id="4-android"><a class="header" href="#4-android">4. Android</a></h2>
<p>The tablet needs the <em>side-by-side</em> build. <strong>Do not uninstall the release app</strong>
to make an install succeed — see "Known open" for why the normal command is
currently wrong.</p>
<pre><code class="language-bash">bun run android:build --device
./scripts/sync-android-sources.sh
cd src-tauri/gen/android &amp;&amp; ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
:app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
-x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
-x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk
</code></pre>
<p>Confirm the package is <code>com.dtourolle.jellytau.debug</code> before installing:</p>
<pre><code class="language-bash">aapt2 dump packagename &lt;apk&gt;
</code></pre>
<p>If it says <code>com.dtourolle.jellytau</code>, the suffix was lost — <strong>stop</strong>, re-sync and
re-assemble. Installing it would try to replace the real app.</p>
<p>Then, with <code>adb logcat</code> capturing:</p>
<ul>
<li>
<p><input disabled="" type="checkbox"/>
Play a video. Picture, sound, and controls.</p>
</li>
<li>
<p><input disabled="" type="checkbox"/>
<strong>Scrub.</strong> The bar has a scale — a duration of <code>0.0</code> means the seek bar has
nothing to scrub against (DR-251).</p>
</li>
<li>
<p><input disabled="" type="checkbox"/>
Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a
transcode in place; declaring otherwise re-opened it (DR-246).</p>
</li>
<li>
<p><input disabled="" type="checkbox"/>
PiP.</p>
</li>
<li>
<p><input disabled="" type="checkbox"/>
Lockscreen: controls respond and position tracks.</p>
</li>
<li>
<p><input disabled="" type="checkbox"/>
<strong>The handoff sequence, in this exact order:</strong>
1. play a video
2. enable background audio
3. background the app — audio continues
4. foreground the app — <strong>video returns</strong>
5. exit the player — <strong>everything stops</strong></p>
<pre><code>Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
the same actions in another order finds neither.
</code></pre>
</li>
<li>
<p><input disabled="" type="checkbox"/>
<code>grep -c 'PANIC at' &lt;logcat&gt;</code> returns 0.</p>
</li>
</ul>
<h2 id="5-regression-checks-with-a-named-cause"><a class="header" href="#5-regression-checks-with-a-named-cause">5. Regression checks with a named cause</a></h2>
<p>Each of these presented as something other than its cause, which is why they are
listed separately from the feature passes above.</p>
<div class="table-wrapper"><table><thead><tr><th>Symptom to look for</th><th>Was actually</th><th>Ref</th></tr></thead><tbody>
<tr><td>Skip on a transcoded item does nothing, or jumps to zero</td><td>Seek strategy keyed on the container, not the engine</td><td>DR-238, DR-246</td></tr>
<tr><td>Play/pause button does not follow the player</td><td>A property handled but never observed, so the event never arrived</td><td>DR-239</td></tr>
<tr><td>Fullscreen leaves a strip of desktop</td><td>The document went fullscreen, the window did not</td><td>DR-240</td></tr>
<tr><td>Resume plays from the beginning</td><td>A seek issued before the engine had a file was discarded</td><td>DR-241</td></tr>
<tr><td>Scrub bar has no scale</td><td>Duration reported as <code>0.0</code> and believed</td><td>DR-251</td></tr>
<tr><td>Black screen, no controls, after a background-audio round trip</td><td>A junk duration converted to a <code>Duration</code> panicked the backend</td><td>DR-252</td></tr>
<tr><td>Audio still playing after leaving the player</td><td>The stop was aimed at whichever renderer bookkeeping believed was active</td><td>DR-250</td></tr>
</tbody></table>
</div>
<h2 id="known-open--decide-do-not-discover"><a class="header" href="#known-open--decide-do-not-discover">Known open — decide, do not discover</a></h2>
<p>None of these are fixed. Each needs an explicit ship / do-not-ship call rather
than being met with surprise during testing.</p>
<ul>
<li><strong>Resume is device-local.</strong> Progress is read from the local database and
nothing consults the server's <code>UserData</code>. A fresh install, a second device or
a reinstall offers no resume even though the server knows the position. Not a
regression — it has always been so.</li>
<li><strong>The background-audio handoff is an unconfirmed state swap.</strong>
<code>exit_background_audio</code> marks the video element the player again the moment it
is called, while the element has not reloaded. DR-250 makes the visible
symptom impossible; the race is intact and can still misdirect a lockscreen
command or a position read. See
<a href="specs/media-player-controller.html">media-player-controller.md</a>.</li>
<li>🔴 <strong>The side-by-side debug install is broken.</strong> <code>bun run android:dev</code>
produces an APK with the <em>release</em> application id, because the Tauri build
regenerates <code>gen/build.gradle.kts</code> after the sync drops the <code>.debug</code> suffix in.
It then fails on signatures, and its own error message advises uninstalling —
which would destroy the real app's data. <strong>Fix this before anyone else builds
for Android.</strong></li>
<li><strong><code>PlayerBackend</code> still exists</strong> behind <code>LegacyPlayer</code>, and the frontend still
carries some playback state. DR-248 and DR-249 are not started.</li>
</ul>
<h2 id="ship-criteria"><a class="header" href="#ship-criteria">Ship criteria</a></h2>
<p>Ship when:</p>
<ol>
<li>Every automated gate in §1 passes.</li>
<li>Conformance matches §2 exactly, deviations included.</li>
<li>§3 and §4 are complete, on real hardware, by a person.</li>
<li>§5 shows no symptom returning.</li>
<li>Every item in "Known open" has a recorded decision.</li>
</ol>
<p>Do not ship on green suites alone. Both regressions introduced during this work
passed every suite and were caught by a person using the app.</p>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="release-checklist.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="build/build-desktop-packages.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="release-checklist.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="build/build-desktop-packages.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>