iOS: presses are felt (#430)

The page asks the app for a haptic ({t:"haptic", kind}) and the app
plays the phone's own. A light tap for play, pause, flag and pull to
refresh; the system's success for marking read, one item or all; its
warning with an error toast. A browser has no host to ask, and an
older app ignores the message, so there is nothing to gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 16:57:51 -05:00
parent 2e031763fa
commit cf0dad7380
12 changed files with 40 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- In the iOS app, presses are felt: play, pause, flag, pull to refresh, marking read and errors each give the phone's haptic for it (#430).
- A sleep timer in the player: 5 minutes to an hour, then playback fades out and pauses. In the iOS app the phone keeps the time, so it works with the screen locked; the app and Safari also get an AirPlay button. On a phone the title has the player's top line to itself, with the controls either side of play below it (#429).
- In the iOS app, Keep on this device keeps the episode on the phone, where the app's own player plays it with no connection; until now the app had no Keep button (#425).
- An episode kept on this device says so in its row of the list, On this device, not only in its files (#413).

View File

@@ -163,7 +163,7 @@ async function feedAction(a: string,f: Feed){
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
catch(e){ spinEnd(); throw e; }
}
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); haptic('done'); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f);
if(a==='pin'){
try{
@@ -190,7 +190,7 @@ async function allAction(a: string){
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
try{
const r=await api('/api/read-all',{method:'POST'});
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
haptic('done'); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(why(e),true); }
}
}

View File

@@ -156,7 +156,7 @@ document.addEventListener('touchend', () => {
if(!g) return;
if(g.pane === 'list'){
pullShow(0);
if(g.dy >= PULL) refreshFeed();
if(g.dy >= PULL){ haptic('tap'); refreshFeed(); }
return;
}
const i = S.entries.findIndex(x => entryKey(x) === S.sel);

View File

@@ -524,11 +524,11 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='play'){ haptic('tap'); play(e, encId!=null ? enc : undefined); }
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); haptic('tap'); redraw(); }
if(a==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
if(a==='get'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});

View File

@@ -24,6 +24,15 @@ const ipxHost: ((m: any) => void) | null = (() => {
})();
if (ipxHost) installNativePlayback();
/// 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();

View File

@@ -172,7 +172,7 @@ audio.addEventListener('playing',()=>{
asked=0; waited=0;
});
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$<HTMLButtonElement>('#pplay').onclick=()=>{haptic('tap');audio.paused?audio.play():audio.pause()};
$<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};

View File

@@ -228,6 +228,7 @@ async function share(title: string, url: string, btn?: HTMLElement){
function toast(msg: string, bad?: boolean){
const t=document.createElement('div'), box=$('#toasts');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
if(bad) haptic('warn');
const go=()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)};
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
// be read again (#303).