The server address and the player token were set from alerts that opened on shaking the phone, which nobody finds. Settings now has a This device section in the app: the server, the player token and its address, and a Make button that creates an API token and hands it straight to the app's keychain. The app sends what it has at ready (appSettings) and takes setServer, setToken and clearToken; a new server rebuilds the web view as the alert did. The first-launch prompt and the Cannot reach alert stay native, since there is no page then. The iPhone is portrait only; the iPad keeps all four orientations, which Split View and Slide Over need (#434 checked the iPad's layout). Build 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
16 KiB
TypeScript
316 lines
16 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();
|
|
|
|
/// 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 `<div class="field boxed" id="sdevice"><label>This device</label>
|
|
<span class="hint">The iPX app's own settings, kept on this phone.</span>
|
|
<label for="sserver">Server</label>
|
|
<div class="inline"><input type="text" inputmode="url" id="sserver" value="${esc(a.server)}" autocapitalize="off" autocomplete="off" spellcheck="false">
|
|
<button class="btn" id="sserverset">Open</button></div>
|
|
<span class="hint">The address the app opens. Plain http only works on your own network, such as 192.168.1.130:8099.</span>
|
|
<label for="sptoken">Player token</label>
|
|
<div class="inline"><input type="password" id="sptoken" autocomplete="off" spellcheck="false"
|
|
placeholder="${a.tokenSet?'Set':'Not set'}">
|
|
<button class="btn" id="sptokenmake" title="Make a token for this device and save it" aria-label="Make a player token">${ICON.plus} Make</button>
|
|
<button class="btn ico" id="sptokenclear" title="Clear the player token" aria-label="Clear the player token"${a.tokenSet?'':' hidden'}>${ICON.trash}</button></div>
|
|
<label for="sapi">Player address</label>
|
|
<div class="inline"><input type="text" inputmode="url" id="sapi" value="${esc(a.api)}" autocapitalize="off" autocomplete="off" spellcheck="false">
|
|
<button class="btn" id="sptokensave">Save</button></div>
|
|
<span class="hint">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.</span></div>`;
|
|
}
|
|
|
|
function wireDeviceSettings(){
|
|
if(!appSettings||!ipxHost) return;
|
|
const host=ipxHost;
|
|
$<HTMLButtonElement>('#sserverset').onclick=()=>{
|
|
const url=$<HTMLInputElement>('#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_=()=>$<HTMLInputElement>('#sapi').value.trim();
|
|
$<HTMLButtonElement>('#sptokensave').onclick=()=>{
|
|
const token=$<HTMLInputElement>('#sptoken').value.trim();
|
|
host({t:'setToken', api:api_(), ...(token?{token}:{})});
|
|
};
|
|
$<HTMLButtonElement>('#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); }
|
|
};
|
|
$<HTMLButtonElement>('#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});
|
|
}
|