#442: a car's pick told the page `replaced`, and the page closed its player bar, so leaving the car with the episode playing left nothing on the phone to pause it. And when the car launched the app, the page loaded later and was never told anything played. Now the app sends `nowPlaying` (the item, its place, playing or not) on a car pick, at the page's `ready`, and on coming forward, and the page's bar takes it up and drives the phone's player. `replaced` is still handled for builds before this one. #443: Shows listed every feed with items, blogs included. Now only feeds with a downloaded file, and an episode only with a downloaded audio file: /media/{id} answers 404 before the download. #444: tests/native-bridge.ts read its failure count before the CarPlay checks, so those could fail and still exit 0. The count is read last now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
This commit is contained in:
@@ -13,7 +13,7 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
|
||||
- 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).
|
||||
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
|
||||
- Siri and Shortcuts: "Resume iPX", "Pause iPX", "Skip forward in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX", with nothing to set up, and each an action in Shortcuts. Resume works with the app closed, carrying on with the last episode from where you left off (#427).
|
||||
- CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest (#420).
|
||||
- CarPlay: In Progress, Unread and Shows on the car's screen, an episode playing from where you left off and saving its place as it does on the phone, and the car's Now Playing for the rest. It lists only shows and episodes with a downloaded audio file, and the phone's player bar takes up what the car plays (#420, #442, #443).
|
||||
- A widget for the home screen and lock screen: the unread count, and the episode you were on with a button to resume or pause it (#428).
|
||||
- The icon's count stays right with the app closed: ipx sends it to the phone when it changes, once its `[push]` is set up, in place of waiting for iOS's background refresh (#433).
|
||||
- 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).
|
||||
|
||||
@@ -134,7 +134,8 @@ App to page, as `window.ipxNative.on(...)`:
|
||||
| `{t:"push", token, sandbox}` | at `ready` and when Apple gives a new one: the phone's push token, for the page to register with ipx (`POST /api/push/devices`) (#433) |
|
||||
| `{t:"sleep", left}` | at `ready`, while the timer runs: seconds left, for a page that reloaded |
|
||||
| `{t:"slept"}` | the timer ran out and playback paused |
|
||||
| `{t:"replaced"}` | the car chose another episode; the page's player bar closes without saving |
|
||||
| `{t:"nowPlaying", feedId, guid, enc, title, feedTitle, artwork, playing, cur, dur}` | what the phone plays that the page did not choose: the car's pick, and at `ready` and on coming forward whatever is loaded; the page's player bar takes it up (#442) |
|
||||
| `{t:"replaced"}` | builds before `nowPlaying`: the car chose another episode, and the page's player bar closes without saving |
|
||||
|
||||
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
|
||||
its own clock, so both happen while the page is frozen. Video stays in the page.
|
||||
|
||||
@@ -43,7 +43,12 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
|
||||
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
|
||||
playback.onSlept = { [weak self] in self?.send(["t": "slept"]) }
|
||||
playback.onReplaced = { [weak self] in self?.send(["t": "replaced"]) }
|
||||
playback.onChosen = { [weak self] in self?.sendNowPlaying() }
|
||||
// Again on coming forward: a page frozen while the car chose may have missed it (#442).
|
||||
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil,
|
||||
queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.sendNowPlaying() }
|
||||
}
|
||||
Push.onToken = { [weak self] _ in self?.sendPush() }
|
||||
Offline.shared.onChange = { [weak self] in self?.sendKept() }
|
||||
Offline.shared.onFail = { [weak self] path, msg in
|
||||
@@ -57,6 +62,19 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
"keeping": Offline.shared.downloading.sorted()])
|
||||
}
|
||||
|
||||
/// What is loaded, for the page's player bar to take up (#442): the car's pick, or an episode
|
||||
/// playing when the page loads, as when the app was opened from CarPlay. Without it the sound
|
||||
/// went on with no bar on the phone to pause it.
|
||||
private func sendNowPlaying() {
|
||||
guard let item = playback.current else { return }
|
||||
var m: [String: Any] = ["t": "nowPlaying", "feedId": item.feedId, "guid": item.guid, "enc": item.enc,
|
||||
"title": item.title, "feedTitle": item.feedTitle,
|
||||
"playing": playback.wantsToPlay, "cur": playback.currentTime]
|
||||
if let a = item.artwork { m["artwork"] = a }
|
||||
if let d = playback.duration ?? item.duration.map(Double.init) { m["dur"] = d }
|
||||
send(m)
|
||||
}
|
||||
|
||||
/// For the page to register with ipx (#433), at ready and whenever Apple gives a new one.
|
||||
private func sendPush() {
|
||||
guard let token = Push.token else { return }
|
||||
@@ -78,6 +96,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
||||
if let at = playback.sleepAt { send(["t": "sleep", "left": max(0, at.timeIntervalSinceNow)]) }
|
||||
sendKept()
|
||||
sendPush()
|
||||
sendNowPlaying()
|
||||
case "load":
|
||||
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
|
||||
let enc = m["enc"] as? Int,
|
||||
|
||||
@@ -13,6 +13,8 @@ struct CarEpisode: Decodable {
|
||||
let url: String
|
||||
let mime: String?
|
||||
let deadair: Double?
|
||||
/// Set once ipx has the file; `/media/{id}` answers 404 before that.
|
||||
let path: String?
|
||||
}
|
||||
|
||||
let guid: String
|
||||
@@ -23,10 +25,17 @@ struct CarEpisode: Decodable {
|
||||
let position: Double
|
||||
let enclosures: [Enclosure]
|
||||
|
||||
/// The file to play: the first that is not a video, which a car would only play the sound of
|
||||
/// with nothing to say so.
|
||||
/// The file to play: the first downloaded one that is sound (items.ts isPlayable, less video,
|
||||
/// which a car would play the sound of with nothing to say so). An item with none is not listed
|
||||
/// (#443): a post's picture or a file still to come is nothing a car can play.
|
||||
var audio: Enclosure? {
|
||||
enclosures.first { !($0.mime ?? "").lowercased().hasPrefix("video/") }
|
||||
enclosures.first { e in
|
||||
guard e.path != nil else { return false }
|
||||
let mime = (e.mime ?? "").lowercased()
|
||||
if !mime.isEmpty { return mime.hasPrefix("audio/") }
|
||||
let ext = URL(string: e.url)?.pathExtension.lowercased() ?? ""
|
||||
return ["mp3", "m4a", "m4b", "aac", "wav", "flac"].contains(ext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Its title, or the file's name: a car's list cannot show the opening of a post as the
|
||||
@@ -66,6 +75,8 @@ struct CarFeed: Decodable {
|
||||
let image: String?
|
||||
let unread: Int?
|
||||
let entries: Int?
|
||||
/// Its files ipx has downloaded.
|
||||
let downloaded: Int?
|
||||
let group: String?
|
||||
|
||||
var name: String { title ?? id }
|
||||
@@ -166,13 +177,14 @@ final class CarScreen: NSObject, CPTabBarTemplateDelegate {
|
||||
}
|
||||
|
||||
/// Alphabetical, which a driver can find a show in without reading the whole list. Only
|
||||
/// shows with items: an OPML subscription's own row holds none, its feeds do.
|
||||
/// shows with files downloaded (#443): a blog has nothing a car can play, and an OPML
|
||||
/// subscription's own row holds no items, its feeds do.
|
||||
private func showFeeds() {
|
||||
guard !feeds.isEmpty else {
|
||||
unreachable(shows)
|
||||
return
|
||||
}
|
||||
let sorted = feeds.values.filter { ($0.entries ?? 0) > 0 }
|
||||
let sorted = feeds.values.filter { ($0.downloaded ?? 0) > 0 }
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
let rows: [CPListItem] = sorted.map { f in
|
||||
let n = f.unread ?? 0
|
||||
|
||||
@@ -29,8 +29,8 @@ final class Playback: NSObject {
|
||||
var onError: ((String) -> Void)?
|
||||
/// The sleep timer ran out and playback paused.
|
||||
var onSlept: (() -> Void)?
|
||||
/// Something the page did not choose is playing now: the car's pick (#420).
|
||||
var onReplaced: (() -> Void)?
|
||||
/// Something the page did not choose is loaded now: the car's pick (#420).
|
||||
var onChosen: (() -> Void)?
|
||||
|
||||
private let session: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
@@ -55,6 +55,8 @@ final class Playback: NSObject {
|
||||
private(set) var sleepAt: Date?
|
||||
|
||||
var isPlaying: Bool { player?.timeControlStatus == .playing }
|
||||
/// Playing, or about to once it has buffered: what a player bar's button should say.
|
||||
var wantsToPlay: Bool { player.map { $0.timeControlStatus != .paused } ?? false }
|
||||
/// What is loaded, for the car's lists to mark.
|
||||
var current: Item? { item }
|
||||
var currentTime: Double { player?.currentTime().seconds ?? 0 }
|
||||
@@ -168,15 +170,15 @@ final class Playback: NSObject {
|
||||
}
|
||||
|
||||
/// An episode chosen away from the page, in the car (#420): played from where it got to, and
|
||||
/// the page told, whose player bar would otherwise go on as the old one, saving its place and
|
||||
/// marking it read by this one's clock.
|
||||
/// the page told before it plays, so its player bar shows this one, not the old one or none
|
||||
/// (#442), and the state that follows reaches it.
|
||||
func playChosen(_ item: Item, startAt: Double?) {
|
||||
if item == self.item {
|
||||
play()
|
||||
return
|
||||
}
|
||||
onReplaced?()
|
||||
load(item, startAt: startAt)
|
||||
onChosen?()
|
||||
play()
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,6 @@ ok(fetched.some(f => f.startsWith('POST /api/push/devices') && f.includes('"sand
|
||||
await vm.runInContext('forgetPush()', ctx);
|
||||
ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone');
|
||||
|
||||
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||
// CarPlay (#420): the car picks another episode, and the page's bar lets go of the old one.
|
||||
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);
|
||||
@@ -206,5 +205,21 @@ audio.removeAttribute('src');
|
||||
ok(!last('stop'), "closing the page's player stopped what the car chose");
|
||||
ok(!posted.some(m => m.t === 'position'), 'the old episode saved its place by the new one\'s clock');
|
||||
|
||||
// The car's pick, or one already playing as the page loads (#442): the bar takes it up, and its
|
||||
// buttons drive the phone's player, where it used to close and leave nothing to pause.
|
||||
since();
|
||||
ctx.window.ipxNative.on({ t: 'nowPlaying', feedId: 'f', guid: 'g2', enc: 7, title: 'From the Car', feedTitle: 'A Feed',
|
||||
playing: true, cur: 120, dur: 1800 });
|
||||
ok(vm.runInContext('player.guid', ctx) === 'g2', 'the bar did not take up what the phone plays');
|
||||
ok(audio.paused === false && audio.currentTime === 120 && audio.duration === 1800, 'the bar is not at the phone\'s place');
|
||||
ok(!last('load') && !last('play'), 'taking it up started it again');
|
||||
audio.pause();
|
||||
ok(last('pause'), "the bar's pause did not reach the phone's player");
|
||||
ctx.window.ipxNative.on({ t: 'state', playing: false });
|
||||
ctx.window.ipxNative.on({ t: 'nowPlaying', feedId: 'f', guid: 'g2', enc: 7, title: 'From the Car', feedTitle: 'A Feed',
|
||||
playing: false, cur: 130 });
|
||||
ok(audio.paused === true && audio.currentTime === 130, 'told again on coming forward, the bar did not follow');
|
||||
|
||||
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||
console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it');
|
||||
process.exit(0);
|
||||
|
||||
@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
|
||||
- In the iOS app, the player bar closes when CarPlay starts a different episode, instead of going on as the old one (#420).
|
||||
- In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442).
|
||||
- In the iOS app, the icon's unread count follows the page's: reading and new items change it at once, not the next time the app is opened (#421).
|
||||
- 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).
|
||||
|
||||
@@ -151,6 +151,34 @@ function installNativePlayback(){
|
||||
return realRemoveAttribute(name);
|
||||
}});
|
||||
|
||||
/// What the phone plays that the page did not choose (#442): the car's pick, or an episode already
|
||||
/// playing when the page loads (the app opened from CarPlay, a reload). The bar takes it up, so it
|
||||
/// can be paused here; it used to close, and the sound went on with nothing on the phone to stop it.
|
||||
function adopt(m: any){
|
||||
if(!(N.on && player.guid===m.guid && player.feed===m.feedId)){
|
||||
// A video the element was playing for itself stops: the phone's player has the sound now.
|
||||
realPause.call(audio); realRemoveAttribute('src'); realLoad.call(audio);
|
||||
document.body.classList.remove('has-video');
|
||||
const e=S.entries.find(x=>x.guid===m.guid&&x.feed_id===m.feedId)||null;
|
||||
const enc=e?.enclosures.find(x=>x.id===m.enc);
|
||||
// Not moved: nothing here played it, so there is no place of the page's own to save.
|
||||
player.guid=m.guid; player.feed=m.feedId; player.entry=e; player.enc=m.enc;
|
||||
player.src=enc?mediaSrc(enc):undefined; player.moved=false; player.marked=false; player.saveAt=m.cur||0;
|
||||
$('#partwrap').innerHTML=artHTML(m.artwork||feedArt(m.feedId),feedName(m.feedId));
|
||||
$('#ptitle').textContent=m.title||'';
|
||||
$('#pfeed').textContent=m.feedTitle||'';
|
||||
$('#player').classList.add('on');
|
||||
N.on=true; N.paused=true; N.dur=NaN; N.ready=0;
|
||||
}
|
||||
N.cur=m.cur||0;
|
||||
if(m.dur!=null){ N.dur=m.dur; N.ready=1; }
|
||||
N.paused=!m.playing;
|
||||
fire('timeupdate');
|
||||
fire(m.playing ? 'play' : 'pause');
|
||||
renderEntries();
|
||||
syncListening();
|
||||
}
|
||||
|
||||
function stop(){
|
||||
if(!N.on) return;
|
||||
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
|
||||
@@ -179,6 +207,7 @@ function installNativePlayback(){
|
||||
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(m.t==='push'){ hostPush(m); return; }
|
||||
if(m.t==='nowPlaying'){ adopt(m); return; }
|
||||
if(!N.on) return;
|
||||
switch(m.t){
|
||||
case 'time':
|
||||
@@ -201,7 +230,7 @@ function installNativePlayback(){
|
||||
fire('ended');
|
||||
break;
|
||||
case 'replaced':
|
||||
// The car chose another episode (#420). The bar would go on as this one, saving its
|
||||
// From app builds before nowPlaying (#442): the car chose another episode (#420). The bar would go on as this one, saving its
|
||||
// place and marking it read by the other's clock, so it closes. No pause event: that
|
||||
// saves, and N.on is off, so it would write the page's stale time to ipx itself.
|
||||
N.on = false; N.paused = true;
|
||||
|
||||
Reference in New Issue
Block a user