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(`