/* ---------------- 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 `
The iPX app's own settings, kept on this phone.
The address the app opens. Plain http only works on your own network, such as 192.168.1.130:8099.
With a token, playback and CarPlay keep working when this page's sign-in ends. Make one here, or paste one from API tokens below. It is kept in the phone's keychain.
`; } 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}); }