Files
ipx/web/src/native.ts
rays d238681ec1 Name an item without a title from its own text, not "(untitled)" (#116)
RSS 2.0 makes an item's title optional, and some blogs leave it out on purpose: Scripting News
titles almost none of its posts. Fifty rows of "(untitled)" said nothing about any of them.

entryName gives an item its title, or the opening of its text (HTML read through DOMParser, an
inert document that loads nothing; cut at a word near 120 characters), or its file's name, or
its show and date, with a flag for a name that is not a title. The list sets that one in the
regular weight, as the text it is rather than a heading; the reader leaves out the heading so
the post starts with itself; the player, the lock screen, Currently Listening, the native shell,
Share and the queued toast use the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:46:47 +00:00

178 lines
7.7 KiB
TypeScript

/* ---------------- native shell bridge ---------------- */
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
// that cannot render a WebView at all, and the only audio they will control is the host's.
//
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
// exactly as they do in a browser, with nothing in player.ts changed.
//
// In a browser none of this installs and the page is untouched.
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
/// which is what switches the whole file off.
const ipxHost: ((m: any) => void) | null = (() => {
const w = window as any;
const ios = w.webkit?.messageHandlers?.ipx;
if (ios) return (m: any) => ios.postMessage(m);
const android = w.ipxAndroid;
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
return null;
})();
if (ipxHost) installNativePlayback();
function installNativePlayback(){
const post = ipxHost!;
const M = HTMLMediaElement.prototype;
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
// Bound before anything is redefined, because the src setter below has to drop the element's
// file without that counting as closing the player: removeAttribute is overridden further down
// to mean exactly that, and going through it there switched the shim straight back off.
const realRemoveAttribute = audio.removeAttribute.bind(audio);
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
const paused = own('paused'), readyState = own('readyState');
const volume = own('volume'), playbackRate = own('playbackRate');
// What the host last told us. `on` is the whole switch: false means this element is playing for
// itself, which is still the case for video -- the host plays audio, and a native video layer
// under a WebView buys nothing when CarPlay is audio-only either way.
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
const fire = (name: string) => audio.dispatchEvent(new Event(name));
const define = (k: string, d: PropertyDescriptor) =>
Object.defineProperty(audio, k, {configurable:true, ...d});
define('play', {value(){
if(!N.on) return realPlay.call(audio);
post({t:'play'});
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
// message from the host instead, so there is nothing to reject.
return Promise.resolve();
}});
define('pause', {value(){
if(!N.on) return realPause.call(audio);
post({t:'pause'});
}});
define('src', {
get(){ return N.on ? '' : src.get!.call(audio); },
set(v){
// has-video is set immediately before the src in play(), so it is already right here.
if(document.body.classList.contains('has-video')){
stop();
src.set!.call(audio, v);
return;
}
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
realPause.call(audio);
realRemoveAttribute('src');
realLoad.call(audio);
const e = player.entry, f = player.feed;
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
title:e ? entryName(e).text : '', feedTitle:feedName(f),
artwork:(e && e.image) || feedArt(f) || null,
// Where the host starts is not this: the seek to where you left off is player.ts's, on
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
// display before the file has loaded.
position:(e && e.position) || 0, duration:(e && e.duration) || null,
rate:audio.playbackRate, volume:audio.volume});
},
});
define('currentTime', {
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
set(v){
if(!N.on){ currentTime.set!.call(audio, v); return; }
N.cur = v;
post({t:'seek', to:v});
// The clock and the scrubber move now rather than at the host's next tick, which is what
// makes the 15 and 30 second keys feel like they did.
fire('timeupdate');
},
});
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
define('volume', {
get(){ return volume.get!.call(audio); },
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
});
define('playbackRate', {
get(){ return playbackRate.get!.call(audio); },
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
});
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
// playing on with nothing on screen to stop it.
define('removeAttribute', {value(name: string){
if(name === 'src') stop();
return realRemoveAttribute(name);
}});
function stop(){
if(!N.on) return;
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
post({t:'stop'});
}
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
// left paused in another tab once saved its older place over where you had got to -- and a
// backgrounded WebView 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 time, and the host
// is also the one saving while nothing here is running at all.
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(url: string, data?: any){
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
return beacon ? beacon(url, data) : false;
} as any;
// What the host calls back into. On `window` deliberately: the host reaches it by name through
// evaluateJavaScript, and a top-level const would work but not obviously.
(window as any).ipxNative = {
version: 1,
on(m: any){
if(!N.on) return;
switch(m.t){
case 'time':
N.cur = m.cur;
if(m.dur != null) N.dur = m.dur;
fire('timeupdate');
break;
case 'meta':
N.dur = m.dur; N.ready = 1;
fire('loadedmetadata');
break;
case 'state':
if(m.playing === !N.paused) return;
N.paused = !m.playing;
fire(m.playing ? 'play' : 'pause');
break;
case 'ended':
N.paused = true;
fire('pause');
fire('ended');
break;
case 'error':
N.paused = true;
fire('pause');
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
break;
}
},
};
// The host waits for this to know the bridge is in and which build it got: an app newer than the
// deployed page would otherwise sit there sending messages nothing answers.
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
}