Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes, light and dark), in Playwright against the test daemon. - #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named by what they show, the open one aria-current. Not role=button, which would flatten a folder's triangle inside it. - #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0. - #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers. - #301: the seek slider is named Position, its value read as "12:30 of 45:00". - #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search and filter boxes have names that do not depend on their placeholder. - #303: #toasts is role=status; an error toast stays until dismissed, at most four at once. - #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the daemon's internals, already in its log); play() failures are put in words and reported, an AbortError ignored; a failed download names its item. - #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80% for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks, phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0, so they take the same focus ring as everything else. - #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it, and makes the rest of the body inert until closeModal, which gives focus back to what opened it. Not <dialog>: its top layer would cover the toasts and their Dismiss. - #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes. - #309: Settings shows iPX's and the page's versions at its foot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
7.8 KiB
TypeScript
179 lines
7.8 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');
|
|
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});
|
|
}
|