A WKWebView runs no service worker, so the page's Keep button (#412) was not shown in the app and everything played from the server. Now the page asks the app to keep the file (keep, unkeep, clearKept), and the app answers with what it holds (kept, keepFailed) whether or not anything plays. The app fetches with a background URLSession, so a download goes on with the app closed, into Application Support (not backed up; the files can be fetched again), and Playback plays from the file when there is one. The DeadAir copy is kept under its own path, as the browser keeps it. Places and finishes reached offline wait in an outbox, newest per address, and go when the network is back. Checked in the simulator against a local server: kept /media/42 and /media/42/deadair, then with the server refusing /media, a fresh launch played each from the phone and asked the server for nothing. The page itself still needs a connection to open (#435). Deploy the app (build 6) before the page: a new page in an old app shows a Keep button nothing answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
7.5 KiB
TypeScript
153 lines
7.5 KiB
TypeScript
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
|
// hand it to the host, while everything in player.ts carries on talking to the element.
|
|
//
|
|
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
|
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
|
// road, with nothing to look at.
|
|
//
|
|
// node tests/native-bridge.ts
|
|
import { makeContext, vm, scriptName } from './dom-stub.ts';
|
|
import { buildPage } from '../web/build.mts';
|
|
|
|
const { html, js: script } = buildPage('index.html');
|
|
|
|
let failed = 0;
|
|
const ok = (cond: unknown, what: string) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
|
|
|
/* ---- a browser: nothing installs ---- */
|
|
{
|
|
const { ctx } = makeContext(html, script, { media: true });
|
|
vm.createContext(ctx);
|
|
vm.runInContext(script, ctx, { filename: scriptName(script, 'browser'), timeout: 5000 });
|
|
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
|
const audio = ctx.document.querySelector('#audio');
|
|
audio.src = '/media/1';
|
|
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
|
}
|
|
|
|
/* ---- inside the shell ---- */
|
|
/// What the page sent the shell: a kind, `t`, and its fields.
|
|
type Posted = { t: string, [field: string]: unknown };
|
|
const posted: Posted[] = [];
|
|
const { ctx, audio: media, body } = makeContext(html, script, { media: true });
|
|
if (!media) throw new Error('makeContext gave no media element for { media: true }');
|
|
const audio = media;
|
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: (m: Posted) => posted.push(m) } } };
|
|
vm.createContext(ctx);
|
|
vm.runInContext(script, ctx, { filename: scriptName(script, 'shell'), timeout: 5000 });
|
|
|
|
const last = (t: string) => [...posted].reverse().find(m => m.t === t);
|
|
const since = () => posted.splice(0, posted.length);
|
|
|
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
|
ok(last('ready'), 'the host was never told the bridge is in');
|
|
since();
|
|
|
|
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
|
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
|
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
|
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
|
|
|
body.classList.toggle('has-video', false);
|
|
audio.calls.length = 0;
|
|
audio.src = '/media/42';
|
|
|
|
const load = last('load');
|
|
ok(load, 'setting the src told the host nothing');
|
|
if (load) {
|
|
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
|
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
|
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
|
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
|
}
|
|
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
|
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
|
|
|
// player.ts sets currentTime=0 straight after the src.
|
|
since();
|
|
audio.currentTime = 0;
|
|
ok(last('seek')?.to === 0, 'a seek did not reach the host');
|
|
|
|
since();
|
|
audio.play();
|
|
ok(last('play'), 'play did not reach the host');
|
|
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
|
|
|
// The host answers, and the page must move as it would have on its own.
|
|
let played = 0, timed = 0;
|
|
audio.addEventListener('play', () => played++);
|
|
audio.addEventListener('timeupdate', () => timed++);
|
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
|
ok(played === 1, 'the page never saw the host start playing');
|
|
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
|
|
|
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
|
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
|
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
|
|
|
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
|
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
|
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
|
|
|
// The 15-second key: a read and a write through the shim.
|
|
since();
|
|
audio.currentTime -= 15;
|
|
ok(last('seek')?.to === 15, 'back 15 seconds did not land at 15');
|
|
|
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
|
since();
|
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
|
ok(/\/position$/.test(String(last('position')?.url ?? '')), 'the position write did not become a request to the host');
|
|
|
|
// Closing the player has to stop the host, not just blank the element.
|
|
since();
|
|
audio.removeAttribute('src');
|
|
ok(last('stop'), 'closing the player left the host playing');
|
|
|
|
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
|
// buys nothing.
|
|
since();
|
|
audio.calls.length = 0;
|
|
body.classList.toggle('has-video', true);
|
|
audio.src = '/media/99';
|
|
ok(!last('load'), 'a video was handed to the host');
|
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
|
|
|
// Kept on the phone (#425): no service worker in a WebView, so the button is the app's to answer.
|
|
const enc42 = { id: 42, deadair: 0, mime: 'audio/mpeg' };
|
|
ok(String(vm.runInContext('keepBtn(X)', Object.assign(ctx, { X: enc42 }))).includes('data-a="offline"'),
|
|
'no Keep button in the app, which has no service worker to keep a file');
|
|
since();
|
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
|
ok(last('keep')?.url === '/media/42', 'Keep did not ask the app for the file');
|
|
ok(vm.runInContext('keeping.has("/media/42")', ctx), 'the button did not show it was on its way');
|
|
|
|
// The app's answer, which comes whether or not anything is playing.
|
|
audio.removeAttribute('src');
|
|
ctx.window.ipxNative.on({ t: 'kept', urls: ['/media/42'], keeping: [] });
|
|
ok(vm.runInContext('isKept(X)', ctx), "the app's list did not reach the page");
|
|
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'still shown as on its way once kept');
|
|
ok(vm.runInContext('mediaSrc(X)', ctx) === '/media/42?offline', 'a kept file is not asked for as kept');
|
|
|
|
since();
|
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
|
ok(last('unkeep')?.url === '/media/42', 'pressing it again did not ask the app to let it go');
|
|
ctx.window.ipxNative.on({ t: 'kept', urls: [], keeping: [] });
|
|
ok(!vm.runInContext('isKept(X)', ctx), 'still kept after the app let it go');
|
|
|
|
// One that could not be kept stops showing as on its way.
|
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
|
ctx.window.ipxNative.on({ t: 'keepFailed', url: '/media/42', message: 'The server said 404.' });
|
|
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'a failed keep still shows as on its way');
|
|
|
|
since();
|
|
await vm.runInContext('clearOffline()', ctx);
|
|
ok(last('clearKept'), 'signing out left the phone holding what was kept');
|
|
|
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
|
console.log('OK: native-bridge: the host takes playback and keeping, and the page follows it');
|
|
process.exit(0);
|