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

@@ -24,6 +24,8 @@ const ipxHost: ((m: any) => void) | null = (() => {
})();
if (ipxHost) installNativePlayback();
// Here, after ipxHost exists: in the app the button waits for the host to say it has a picker.
paintRoute();
function installNativePlayback(){
const post = ipxHost!;
@@ -143,6 +145,9 @@ function installNativePlayback(){
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(!N.on) return;
switch(m.t){
case 'time':

View File

@@ -177,6 +177,62 @@ $<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
/// What the app said it can do itself (`{t:'host', can}`, native.ts): empty in a browser, and in
/// an app older than the page, which then gets the page's own way of doing it or none.
const hostCan=new Set<string>();
/// The sleep timer (#429): playback pauses after a while, fading out over the last seconds. In
/// the app the phone's player keeps the time, because the page is frozen once the screen locks
/// and a timer of its own would not fire until the app was opened again; a browser plays on in a
/// background tab, so there the page keeps it.
let sleepAt=0, sleepTimer=0, sleepPaint=0;
const sleepLabel=(ms: number)=>ms>0?`${Math.ceil(ms/60000)} min left`:'Sleep';
function paintSleep(){
const s=$<HTMLSelectElement>('#sleep');
s.options[0].textContent=sleepLabel(sleepAt-Date.now());
s.value='';
clearInterval(sleepPaint);
if(sleepAt) sleepPaint=setInterval(paintSleep,15000);
}
function sleepIn(secs: number){
clearTimeout(sleepTimer); sleepTimer=0;
sleepAt=secs>0?Date.now()+secs*1000:0;
if(hostCan.has('sleep')) ipxHost!({t:'sleep',secs});
else if(sleepAt) sleepTimer=setTimeout(sleepFade,Math.max(0,secs*1000-FADE_MS));
paintSleep();
}
const FADE_MS=10000;
/// A fade, not a cut: a sudden stop wakes the person it was set for.
function sleepFade(){
const v=audio.volume, steps=FADE_MS/250; let n=0;
const step=setInterval(()=>{
// Called off since it began: the volume goes back to what it was.
if(!sleepAt){ clearInterval(step); audio.volume=v; return; }
if(++n<steps){ audio.volume=v*(1-n/steps); return; }
clearInterval(step); audio.pause(); audio.volume=v; sleepDone();
},250);
}
function sleepDone(){ sleepAt=0; clearTimeout(sleepTimer); sleepTimer=0; paintSleep(); }
/// The app's side of it: how long is left after the page reloads, and that it has paused.
function hostSleep(m: {t: string, left?: number}){
if(m.t==='slept'){ sleepDone(); return; }
sleepAt=m.left?Date.now()+m.left*1000:0;
paintSleep();
}
$<HTMLSelectElement>('#sleep').onchange=(e: Event)=>sleepIn(Number((e.target as HTMLSelectElement).value));
/// Another speaker or a TV. In the app, the phone's own picker, since the phone is what plays; in
/// Safari, the browser's. Elsewhere there is none to offer.
function paintRoute(){
$<HTMLButtonElement>('#proute').hidden=ipxHost?!hostCan.has('route'):!('webkitShowPlaybackTargetPicker' in audio);
}
// First painted by native.ts, which comes later in the script and declares ipxHost.
$<HTMLButtonElement>('#proute').onclick=(e: Event)=>{
if(!ipxHost){ (audio as any).webkitShowPlaybackTargetPicker(); return; }
// Where the button is, for the picker to point at on an iPad.
const r=(e.currentTarget as HTMLElement).getBoundingClientRect();
ipxHost({t:'route',x:r.left,y:r.top,w:r.width,h:r.height});
};
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
@@ -220,7 +276,7 @@ async function switchVersion(){
if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{});
return true;
}
$<HTMLButtonElement>('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
$<HTMLButtonElement>('#pclose').onclick=()=>{if(sleepAt)sleepIn(0);savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){

View File

@@ -67,6 +67,8 @@ const ICON: Record<string,string>={
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
// Drawn here: Font Awesome's free set has no AirPlay. A screen with a triangle under it, as Apple's.
airplay:fa('0 0 512 512','<path fill="currentColor" d="M64 48l384 0c26.5 0 48 21.5 48 48l0 256c0 26.5-21.5 48-48 48l-56 0 0-56 48 0 0-240-368 0 0 240 48 0 0 56-56 0c-26.5 0-48-21.5-48-48L16 96c0-26.5 21.5-48 48-48zM256 288l144 176-288 0z"/>'),
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
};
// What is playing, as the icon's EQ bars; the stylesheet moves them.