/* ---------------- 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();
/// All Subscriptions' unread count, for the app's icon (#421), each time the list is drawn with a
/// new one: items read in the page and new ones arriving set it at once, where the app's own fetch
/// waited for the app to come forward again.
let badgeSent=-1;
function hostBadge(n: number){
// As haptic: the list can be drawn before this file has declared what it uses.
try{ if(n!==badgeSent){ badgeSent=n; ipxHost?.({t:'unread', n}); } }catch{}
}
/// This phone's push token (#433), which the app hands over at ready: registered with ipx here,
/// signed in as the page is, so ipx can keep the icon's count right while the app is closed.
let pushToken='';
function hostPush(m: {token: string, sandbox?: boolean}){
pushToken=m.token;
api('/api/push/devices',{method:'POST',body:JSON.stringify({token:m.token,sandbox:!!m.sandbox})}).catch(()=>{});
}
/// Signing out: the phone's icon is no longer this person's to count.
async function forgetPush(){
if(pushToken) await api(`/api/push/devices/${encodeURIComponent(pushToken)}`,{method:'DELETE'}).catch(()=>{});
}
/// A press felt as well as seen, in the app (#430): the phone's haptics, which a page cannot reach.
/// `tap` for play, pause and the like, `done` for something finished (marked read), `warn` for an
/// error. An app without them ignores the message; a browser has no host to send it to.
function haptic(kind: 'tap'|'done'|'warn'){
// An error toasted while the script is still loading comes here before this file has declared
// ipxHost, which then throws; a haptic is not worth breaking that error's toast for.
try{ ipxHost?.({t:'haptic', kind}); }catch{}
}
// Here, after ipxHost exists: in the app the button waits for the host to say it has a picker.
paintRoute();
/// The app's own settings (#445): which server it opens, and the player token it sends instead of
/// the page's cookie (#419). They were in an alert that opened on shaking the phone, which nobody
/// found. The app sends them at ready and after each change; null in a browser, or in an app that
/// keeps them elsewhere, and then Settings shows nothing for them.
type AppSettings = {server: string, api: string, tokenSet: boolean, saved?: boolean};
let appSettings: AppSettings|null=null;
function hostAppSettings(m: AppSettings){
appSettings=m;
if(m.saved) toast('Saved on this device.');
// Settings open: drawn again, so "Not set" turns to "Set" under the person's eyes.
const box=$('#sdevice');
if(box){ box.outerHTML=deviceSettingsHTML(); wireDeviceSettings(); }
}
function deviceSettingsHTML(){
const a=appSettings;
if(!a) return '';
return `
`;
}
function wireDeviceSettings(){
if(!appSettings||!ipxHost) return;
const host=ipxHost;
$('#sserverset').onclick=()=>{
const url=$('#sserver').value.trim();
// The app checks it, and on a new server loads it in place of this page.
if(url&&url!==appSettings?.server) host({t:'setServer', url});
};
const api_=()=>$('#sapi').value.trim();
$('#sptokensave').onclick=()=>{
const token=$('#sptoken').value.trim();
host({t:'setToken', api:api_(), ...(token?{token}:{})});
};
$('#sptokenmake').onclick=async()=>{
try{
const t=await api('/api/tokens',{method:'POST',body:JSON.stringify({name:'iPX app on a phone'})});
host({t:'setToken', api:api_(), token:t.token});
drawTokens();
}catch(err){ toast(why(err),true); }
};
$('#sptokenclear').onclick=()=>host({t:'clearToken'});
}
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);
}});
/// What the phone plays that the page did not choose (#442): the car's pick, or an episode already
/// playing when the page loads (the app opened from CarPlay, a reload). The bar takes it up, so it
/// can be paused here; it used to close, and the sound went on with nothing on the phone to stop it.
function adopt(m: any){
if(!(N.on && player.guid===m.guid && player.feed===m.feedId)){
// A video the element was playing for itself stops: the phone's player has the sound now.
realPause.call(audio); realRemoveAttribute('src'); realLoad.call(audio);
document.body.classList.remove('has-video');
const e=S.entries.find(x=>x.guid===m.guid&&x.feed_id===m.feedId)||null;
const enc=e?.enclosures.find(x=>x.id===m.enc);
// Not moved: nothing here played it, so there is no place of the page's own to save.
player.guid=m.guid; player.feed=m.feedId; player.entry=e; player.enc=m.enc;
player.src=enc?mediaSrc(enc):undefined; player.moved=false; player.marked=false; player.saveAt=m.cur||0;
$('#partwrap').innerHTML=artHTML(m.artwork||feedArt(m.feedId),feedName(m.feedId));
$('#ptitle').textContent=m.title||'';
$('#pfeed').textContent=m.feedTitle||'';
$('#player').classList.add('on');
N.on=true; N.paused=true; N.dur=NaN; N.ready=0;
}
N.cur=m.cur||0;
if(m.dur!=null){ N.dur=m.dur; N.ready=1; }
N.paused=!m.playing;
fire('timeupdate');
fire(m.playing ? 'play' : 'pause');
renderEntries();
syncListening();
}
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){
// Not playback's, so not gated on it: the app says what it keeps whether or not it plays.
if(m.t==='kept'||m.t==='keepFailed'){ hostKept(m); return; }
// Sent at ready, so a page newer than the app offers only what the app can do.
if(m.t==='host'){ hostCan.clear(); for(const c of m.can||[]) hostCan.add(c); paintRoute(); return; }
if(m.t==='sleep'||m.t==='slept'){ hostSleep(m); return; }
if(m.t==='push'){ hostPush(m); return; }
if(m.t==='appSettings'){ hostAppSettings(m); return; }
if(m.t==='appSettingsError'){ toast(m.message||'The app did not take that.', true); return; }
if(m.t==='nowPlaying'){ adopt(m); return; }
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 'replaced':
// From app builds before nowPlaying (#442): the car chose another episode (#420). The bar would go on as this one, saving its
// place and marking it read by the other's clock, so it closes. No pause event: that
// saves, and N.on is off, so it would write the page's stale time to ipx itself.
N.on = false; N.paused = true;
player.guid = null;
$('#player').classList.remove('on');
renderEntries();
syncListening();
break;
case 'error':
N.paused = true;
fire('pause');
report('player_error', {msg: `native: ${m.message || 'error'}`, ...playing()});
toast('Unable to play this file. Try again, or download it again.', 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});
}