Player: a sleep timer, and AirPlay in the app (#429)

The sleep timer pauses after 5 minutes to an hour, fading out over the
last ten seconds. In the iOS app the phone's player keeps the time:
with the screen locked the web view is frozen, and a timer in the page
would not fire until the app was opened again. A browser plays on in a
background tab, so there the page keeps it.

The app says at ready what it does itself ({t:"host", can}), so a page
deployed ahead of the app falls back to its own timer and shows no
AirPlay button an older app would ignore. This deploy order problem
came up with #425.

AirPlay opens AVRoutePickerView by pressing its button from code; there
is no API to present it. On iOS 27 the picker opens on the button's
primary action, not touch-up, so that is what is sent, with touch-up
for versions that have nothing on it. The simulator has no AirPlay
devices and never showed the picker, so this is unverified until it
runs on a phone.

On a phone the player's one line left the title two letters wide once
the two controls were added, so the title takes the top line and the
controls flank play below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 16:54:08 -05:00
parent ecc248307e
commit 2e031763fa
13 changed files with 193 additions and 6 deletions

View File

@@ -147,6 +147,29 @@ since();
await vm.runInContext('clearOffline()', ctx);
ok(last('clearKept'), 'signing out left the phone holding what was kept');
// The sleep timer and AirPlay (#429): the app's to do, once it says it can.
// The stub hands out a new element on each lookup, so the select's painting is left out.
vm.runInContext('paintSleep=()=>{}', ctx);
since();
vm.runInContext('sleepIn(900)', ctx);
ok(!last('sleep'), 'the sleep timer went to an app that never said it has one');
ok(vm.runInContext('sleepTimer', ctx), 'with an app that has no timer, the page did not keep the time');
vm.runInContext('sleepIn(0)', ctx);
ctx.window.ipxNative.on({ t: 'host', can: ['sleep', 'route'] });
ok(vm.runInContext('hostCan.has("route")', ctx), "the app's picker was not taken up");
since();
vm.runInContext('sleepIn(900)', ctx);
ok(last('sleep')?.secs === 900, 'the sleep timer did not reach the app');
ok(!vm.runInContext('sleepTimer', ctx), 'the page kept a timer of its own beside the app\'s');
ctx.window.ipxNative.on({ t: 'slept' });
ok(vm.runInContext('sleepAt', ctx) === 0, 'the timer still shows after the app paused for it');
ctx.window.ipxNative.on({ t: 'sleep', left: 600 });
ok(vm.runInContext('sleepAt', ctx) > Date.now() + 590_000, 'a reloaded page was not told how long is left');
since();
vm.runInContext('sleepIn(0)', ctx);
ok(last('sleep')?.secs === 0, 'turning the timer off did not reach the app');
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');
console.log('OK: native-bridge: the host takes playback, keeping and the sleep timer, and the page follows it');
process.exit(0);

View File

@@ -79,6 +79,14 @@ test('DeadAir savings read as time', () => {
assert.equal(run('shorter(150)'), '3 min shorter');
});
test('the sleep timer reads as minutes left, rounded up', () => {
assert.equal(run('sleepLabel(0)'), 'Sleep');
assert.equal(run('sleepLabel(-5)'), 'Sleep');
assert.equal(run('sleepLabel(1000)'), '1 min left');
assert.equal(run('sleepLabel(15 * 60000)'), '15 min left');
assert.equal(run('sleepLabel(15 * 60000 + 1)'), '16 min left');
});
test('initials and plural', () => {
assert.equal(run(`initials('The Verge')`), 'TV');
assert.equal(run(`initials('accidental tech podcast')`), 'AT');