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:
@@ -11,3 +11,4 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
|
|||||||
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
|
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
|
||||||
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
|
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
|
||||||
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
|
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
|
||||||
|
- Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430).
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ Page to app, on `webkit.messageHandlers.ipx`:
|
|||||||
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
|
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
|
||||||
| `{t:"clearKept"}` | signing out: everything kept goes |
|
| `{t:"clearKept"}` | signing out: everything kept goes |
|
||||||
| `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) |
|
| `{t:"sleep", secs}` | pause after `secs`, fading out over the last ten; 0 turns the timer off (#429) |
|
||||||
|
| `{t:"haptic", kind}` | a press felt: `tap`, `done` (marked read) or `warn` (an error) (#430) |
|
||||||
| `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
|
| `{t:"route", x, y, w, h}` | open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
|
||||||
|
|
||||||
App to page, as `window.ipxNative.on(...)`:
|
App to page, as `window.ipxNative.on(...)`:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
|
|
||||||
private weak var webView: WKWebView?
|
private weak var webView: WKWebView?
|
||||||
private let playback: Playback
|
private let playback: Playback
|
||||||
|
private let impact = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
private let notice = UINotificationFeedbackGenerator()
|
||||||
/// The system's AirPlay picker, which has no API to open it but its own button: kept on the
|
/// The system's AirPlay picker, which has no API to open it but its own button: kept on the
|
||||||
/// web view, invisible, and pressed when the page's button is.
|
/// web view, invisible, and pressed when the page's button is.
|
||||||
private lazy var routePicker: AVRoutePickerView = {
|
private lazy var routePicker: AVRoutePickerView = {
|
||||||
@@ -91,6 +93,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
case "clearKept": Offline.shared.removeAll()
|
case "clearKept": Offline.shared.removeAll()
|
||||||
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
|
case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0)
|
||||||
case "route": showRoutes(m)
|
case "route": showRoutes(m)
|
||||||
|
case "haptic": haptic(m["kind"] as? String)
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,16 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
button.sendActions(for: primary ? .primaryActionTriggered : .touchUpInside)
|
button.sendActions(for: primary ? .primaryActionTriggered : .touchUpInside)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page's presses felt (#430): light for a tap, the system's success and warning for
|
||||||
|
/// something done and something gone wrong, as Mail and Messages use them.
|
||||||
|
private func haptic(_ kind: String?) {
|
||||||
|
switch kind {
|
||||||
|
case "done": notice.notificationOccurred(.success)
|
||||||
|
case "warn": notice.notificationOccurred(.warning)
|
||||||
|
default: impact.impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func controls(in v: UIView) -> [UIControl] {
|
private static func controls(in v: UIView) -> [UIControl] {
|
||||||
v.subviews.flatMap { ($0 as? UIControl).map { [$0] } ?? controls(in: $0) }
|
v.subviews.flatMap { ($0 as? UIControl).map { [$0] } ?? controls(in: $0) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: iPX
|
CFBundleDisplayName: iPX
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "7"
|
CFBundleVersion: "8"
|
||||||
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
||||||
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ since();
|
|||||||
vm.runInContext('sleepIn(0)', ctx);
|
vm.runInContext('sleepIn(0)', ctx);
|
||||||
ok(last('sleep')?.secs === 0, 'turning the timer off did not reach the app');
|
ok(last('sleep')?.secs === 0, 'turning the timer off did not reach the app');
|
||||||
|
|
||||||
|
// Haptics (#430): the page asks, the phone does.
|
||||||
|
since();
|
||||||
|
vm.runInContext('toast("Unable to play", true)', ctx);
|
||||||
|
ok(last('haptic')?.kind === 'warn', 'an error was not felt');
|
||||||
|
|
||||||
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||||
console.log('OK: native-bridge: the host takes playback, keeping and the sleep timer, and the page follows it');
|
console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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).
|
- 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).
|
- 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).
|
- An episode kept on this device says so in its row of the list, On this device, not only in its files (#413).
|
||||||
|
|||||||
@@ -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})}); }
|
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
catch(e){ spinEnd(); throw e; }
|
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==='rm') removeFeed(f);
|
||||||
if(a==='pin'){
|
if(a==='pin'){
|
||||||
try{
|
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;
|
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
|
||||||
try{
|
try{
|
||||||
const r=await api('/api/read-all',{method:'POST'});
|
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); }
|
}catch(e){ toast(why(e),true); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ document.addEventListener('touchend', () => {
|
|||||||
if(!g) return;
|
if(!g) return;
|
||||||
if(g.pane === 'list'){
|
if(g.pane === 'list'){
|
||||||
pullShow(0);
|
pullShow(0);
|
||||||
if(g.dy >= PULL) refreshFeed();
|
if(g.dy >= PULL){ haptic('tap'); refreshFeed(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const i = S.entries.findIndex(x => entryKey(x) === S.sel);
|
const i = S.entries.findIndex(x => entryKey(x) === S.sel);
|
||||||
|
|||||||
@@ -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 enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
try{
|
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==='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==='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==='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); redraw(); }
|
if(a==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='get'){
|
||||||
if(!enc) return;
|
if(!enc) return;
|
||||||
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ const ipxHost: ((m: any) => void) | null = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
if (ipxHost) installNativePlayback();
|
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.
|
// Here, after ipxHost exists: in the app the button waits for the host to say it has a picker.
|
||||||
paintRoute();
|
paintRoute();
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ audio.addEventListener('playing',()=>{
|
|||||||
asked=0; waited=0;
|
asked=0; waited=0;
|
||||||
});
|
});
|
||||||
audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); });
|
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>('#pback').onclick=()=>audio.currentTime-=15;
|
||||||
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
$<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};
|
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ async function share(title: string, url: string, btn?: HTMLElement){
|
|||||||
function toast(msg: string, bad?: boolean){
|
function toast(msg: string, bad?: boolean){
|
||||||
const t=document.createElement('div'), box=$('#toasts');
|
const t=document.createElement('div'), box=$('#toasts');
|
||||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
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)};
|
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
|
// An error stays until it is put away: gone after six seconds, it was easy to miss and could not
|
||||||
// be read again (#303).
|
// be read again (#303).
|
||||||
|
|||||||
Reference in New Issue
Block a user