Hand playback to a native shell (#45)
CarPlay and Android Auto cannot render a web view. Both are template
surfaces, and the only audio they will control is the host's own AVPlayer
or ExoPlayer -- so an app that is "the web UI plus CarPlay" is really "the
web UI whose audio engine is native", and the page had no way to give
playback away.
web/src/native.ts replaces the playback surface of the page's media element
with one that forwards to the host and synthesises the events back. Nothing
in player.ts changes: it only ever speaks to the element, so the player bar,
the row buttons, the EQ bars and the keyboard shortcuts keep working as they
did. Video stays in the page, since CarPlay is audio-only and a native video
layer under a web view buys nothing. In a browser none of it installs.
Position and read are the host's to write. player.ts has been bitten before
by a stale position -- one left paused in another tab saved its older place
over where you had got to -- and a backgrounded web view is exactly that
tab: frozen, holding a time from minutes ago, while the host plays on. So
the beacon becomes a request for the host to save its own clock.
tests/native-bridge.js is what holds the two ends together, and it earned
its place immediately: the src setter called removeAttribute('src'), which
the shim's own override turned into a stop() that switched it back off one
line after enabling it. Silent, and only visible in a car. The stub DOM
moved to tests/dom-stub.js so that test and page-smoke share one harness
rather than two copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
131
tests/dom-stub.js
Normal file
131
tests/dom-stub.js
Normal file
@@ -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 };
|
||||
Reference in New Issue
Block a user