/* ---------------- 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}); }