diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22e5c63..04ce966 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
goes solid when the system asks for less transparency or more contrast.
+- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
+ plays through the host's own player instead of the page's, so it keeps going when the screen
+ locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
+ work as they always did. Video still plays in the page. In a browser nothing changes.
## [0.8.3] - 2026-09-19
diff --git a/CLAUDE.md b/CLAUDE.md
index ee2ffc5..885bf08 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,6 +122,7 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
npx tsc -p . # type-checks web/src
node tests/page-smoke.js
+node tests/native-bridge.js # the page hands playback to a native shell
node tests/contrast.js # every theme's palette against WCAG AA
npx playwright test # 40 browser tests against a real daemon on fixture feeds
```
diff --git a/README.md b/README.md
index a35630a..8d2fe7c 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
```sh
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
node tests/page-smoke.js # the page script loads without throwing
+node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```
diff --git a/docs/architecture.md b/docs/architecture.md
index c675006..674094f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -141,6 +141,7 @@ before they reach the page.
```sh
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
+node tests/native-bridge.js # the page hands playback to a native shell
npx playwright test # a real browser against a real daemon on fixture feeds
```
diff --git a/tests/dom-stub.js b/tests/dom-stub.js
new file mode 100644
index 0000000..121dc21
--- /dev/null
+++ b/tests/dom-stub.js
@@ -0,0 +1,131 @@
+// The stub DOM the page's script is loaded against, shared by page-smoke.js and
+// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
+// to find what it reaches for, and no more.
+//
+// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
+// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
+// native.ts replaces that surface on the element and a proxy that answers everything would prove
+// nothing about whether it worked.
+const vm = require('vm');
+
+function makeContext(html, script, { media = false } = {}) {
+ // Ids in the page, and in the markup the script builds for its dialogs.
+ const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
+ const missing = [];
+
+ const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
+ value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
+ appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
+ setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
+ replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
+ play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
+ { get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
+
+ const body = el('body');
+ const audio = media ? makeMediaElement() : null;
+ if (media) {
+ // native.ts reads has-video off the body to decide whether the host takes the file, so this
+ // one has to be a real set rather than something that always says no.
+ const classes = new Set();
+ body.classList = {
+ add: c => classes.add(c), remove: c => classes.delete(c),
+ toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
+ contains: c => classes.has(c),
+ };
+ }
+
+ const document = {
+ querySelector(sel) {
+ if (sel === '#audio' && audio) return audio;
+ if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
+ return el(sel);
+ },
+ querySelectorAll: () => [],
+ createElement: () => el('created'),
+ addEventListener(){}, body,
+ documentElement: { dataset: {} },
+ };
+
+ const ctx = {
+ document, console,
+ window: { isSecureContext: false, addEventListener(){} },
+ localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
+ navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
+ fetch: (url) => Promise.resolve({
+ ok: true, status: 200, text: () => Promise.resolve(''),
+ json: () => Promise.resolve(
+ String(url).includes('/api/settings')
+ ? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
+ : String(url).includes('/api/users')
+ ? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
+ : /\/api\/(popular|directory)/.test(String(url))
+ ? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
+ { id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
+ : /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
+ }),
+ EventSource: function () { this.close = () => {}; },
+ MediaMetadata: function () {},
+ Blob: function () {},
+ setTimeout, clearTimeout, setInterval, clearInterval,
+ confirm: () => false, prompt: () => null, alert(){},
+ Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
+ URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
+ };
+ if (media) {
+ ctx.HTMLMediaElement = MediaElement;
+ ctx.Event = Event;
+ ctx.isFinite = isFinite;
+ ctx.CSS = { escape: s => String(s) };
+ }
+ ctx.globalThis = ctx;
+ ctx.window.location = { href: '', hash: '' };
+ ctx.location = ctx.window.location;
+
+ return { ctx, missing, audio, body, ids };
+}
+
+/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
+
+function Event(type) { this.type = type; }
+
+function MediaElement() {
+ this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
+ this._ready = 0; this._vol = 1; this._rate = 1;
+ this._listeners = {};
+ this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
+ // Every call that reached the real element, so a test can say the host took over rather than
+ // the element quietly playing as well.
+ this.calls = [];
+}
+MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
+MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
+MediaElement.prototype.load = function(){ this.calls.push('load'); };
+MediaElement.prototype.addEventListener = function(name, fn, opts){
+ (this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
+};
+MediaElement.prototype.dispatchEvent = function(ev){
+ for (const l of (this._listeners[ev.type] || []).slice()) {
+ if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
+ l.fn(ev);
+ }
+ return true;
+};
+MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
+MediaElement.prototype.setAttribute = function(){};
+MediaElement.prototype.getAttribute = function(){ return null; };
+const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
+ configurable: true,
+ get(){ return this[field]; },
+ set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
+});
+accessor('src', '_src', true);
+accessor('currentTime', '_t', true);
+accessor('volume', '_vol');
+accessor('playbackRate', '_rate');
+Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
+Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
+Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
+
+function makeMediaElement(){ return new MediaElement(); }
+
+module.exports = { makeContext, vm };
diff --git a/tests/native-bridge.js b/tests/native-bridge.js
new file mode 100644
index 0000000..8f1223d
--- /dev/null
+++ b/tests/native-bridge.js
@@ -0,0 +1,117 @@
+// The page inside a native shell: web/src/native.ts should take playback off the element and
+// hand it to the host, while everything in player.ts carries on talking to the element.
+//
+// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
+// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
+// alone, so a change there that steps outside it would otherwise break the app in a car, on a
+// road, with nothing to look at.
+//
+// node tests/native-bridge.js
+const { makeContext, vm } = require('./dom-stub.js');
+const { buildPage } = require('../web/build.mjs');
+
+const { html, js: script } = buildPage('index.html');
+
+let failed = 0;
+const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
+
+/* ---- a browser: nothing installs ---- */
+{
+ const { ctx } = makeContext(html, script, { media: true });
+ vm.createContext(ctx);
+ vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
+ ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
+ const audio = ctx.document.querySelector('#audio');
+ audio.src = '/media/1';
+ ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
+}
+
+/* ---- inside the shell ---- */
+const posted = [];
+const { ctx, audio, body } = makeContext(html, script, { media: true });
+ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
+vm.createContext(ctx);
+vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
+
+const last = t => [...posted].reverse().find(m => m.t === t);
+const since = () => posted.splice(0, posted.length);
+
+ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
+ok(last('ready'), 'the host was never told the bridge is in');
+since();
+
+// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
+const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
+ enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
+vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
+vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
+
+body.classList.toggle('has-video', false);
+audio.calls.length = 0;
+audio.src = '/media/42';
+
+const load = last('load');
+ok(load, 'setting the src told the host nothing');
+if (load) {
+ ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
+ ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
+ ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
+ ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
+}
+ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
+ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
+
+// player.ts sets currentTime=0 straight after the src.
+since();
+audio.currentTime = 0;
+ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
+
+since();
+audio.play();
+ok(last('play'), 'play did not reach the host');
+ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
+
+// The host answers, and the page must move as it would have on its own.
+let played = 0, timed = 0;
+audio.addEventListener('play', () => played++);
+audio.addEventListener('timeupdate', () => timed++);
+ctx.window.ipxNative.on({ t: 'state', playing: true });
+ok(played === 1, 'the page never saw the host start playing');
+ok(audio.paused === false, 'audio.paused still says paused while the host plays');
+
+ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
+ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
+ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
+
+ctx.window.ipxNative.on({ t: 'time', cur: 30 });
+ok(audio.currentTime === 30, "the host's clock did not reach the page");
+ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
+
+// The 15-second key: a read and a write through the shim.
+since();
+audio.currentTime -= 15;
+ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
+
+// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
+since();
+const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
+ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
+ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
+
+// Closing the player has to stop the host, not just blank the element.
+since();
+audio.removeAttribute('src');
+ok(last('stop'), 'closing the player left the host playing');
+
+// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
+// buys nothing.
+since();
+audio.calls.length = 0;
+body.classList.toggle('has-video', true);
+audio.src = '/media/99';
+ok(!last('load'), 'a video was handed to the host');
+ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
+
+if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
+console.log('OK: native-bridge: the host takes playback and the page follows it');
+process.exit(0);
diff --git a/tests/page-smoke.js b/tests/page-smoke.js
index 909331e..35e7c5b 100644
--- a/tests/page-smoke.js
+++ b/tests/page-smoke.js
@@ -7,7 +7,7 @@
// and every server-side test passed too, because the server was fine.
//
// node tests/page-smoke.js
-const vm = require('vm');
+const { makeContext, vm } = require('./dom-stub.js');
const PAGE = process.argv[2] || 'index.html';
const { buildPage } = require('../web/build.mjs');
@@ -19,57 +19,7 @@ if (!//.test(html) && P
if (!new RegExp(`