From e6414941abbffc9f9acace7867bea2e1e1d9ce31 Mon Sep 17 00:00:00 2001 From: Ray Slakinski <19767+rays@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:18:43 -0500 Subject: [PATCH] iOS: the icon's count follows the page's (#421) Reported: the unread count on the icon only changed when the app was opened. Two reasons: - Items read in the page never reached it, since only the player's own mark-read refreshed the badge. - The background refresh that should bring new items runs when iOS chooses, which for this app has been rarely. The page now sends All Subscriptions' count ({t:"unread", n}) each time its list is drawn with a new one, so reading, and new items arriving over the event stream while the app is open, set it at once. While an episode plays the app is awake with the screen locked, so the player's tick also re-checks every quarter of an hour. With the app closed and nothing playing, the count still waits on iOS's refresh. Push (#433) is what makes that exact. Co-Authored-By: Claude Opus 5.5 --- ios/CHANGELOG.md | 2 +- ios/README.md | 1 + ios/Sources/Badge.swift | 19 ++++++++++++++++++- ios/Sources/Bridge.swift | 1 + ios/Sources/Playback.swift | 1 + ios/project.yml | 2 +- tests/native-bridge.ts | 5 +++++ web/CHANGELOG.md | 1 + web/src/feeds.ts | 1 + web/src/native.ts | 9 +++++++++ 10 files changed, 39 insertions(+), 3 deletions(-) diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 2c4d1a9..5e4db68 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -8,7 +8,7 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1. - iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418). - Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419). -- 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). Reading in the app and new items arriving while it is open set it at once, and while an episode plays it is checked every quarter of an hour. - 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). - 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). diff --git a/ios/README.md b/ios/README.md index 22e7cef..5f20539 100644 --- a/ios/README.md +++ b/ios/README.md @@ -90,6 +90,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:"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:"unread", n}` | All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) | | `{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) | diff --git a/ios/Sources/Badge.swift b/ios/Sources/Badge.swift index 34f1116..d38069f 100644 --- a/ios/Sources/Badge.swift +++ b/ios/Sources/Badge.swift @@ -38,6 +38,23 @@ enum Badge { UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } } + /// The page's own count, sent as its list changes (#421). + @MainActor static func set(_ n: Int) { + lastSet = Date() + Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) } + } + + /// When the icon was last set from a count, by either way. + @MainActor private static var lastSet = Date.distantPast + + /// While an episode plays with the screen locked the app is awake, unlike the page: so new + /// items reach the icon every quarter of an hour, which background refresh does not promise. + @MainActor static func refreshIfStale() { + guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return } + lastSet = Date() + Task { await refresh() } + } + /// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer /// with a feed list: an old number is better than a wrong one. @discardableResult @@ -52,7 +69,7 @@ enum Badge { guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200, let n = unread(inAll: data) else { return false } - try? await UNUserNotificationCenter.current().setBadgeCount(n) + await MainActor.run { set(n) } return true } diff --git a/ios/Sources/Bridge.swift b/ios/Sources/Bridge.swift index 06b817b..210de61 100644 --- a/ios/Sources/Bridge.swift +++ b/ios/Sources/Bridge.swift @@ -94,6 +94,7 @@ final class Bridge: NSObject, WKScriptMessageHandler { case "sleep": playback.sleep(after: m["secs"] as? Double ?? 0) case "route": showRoutes(m) case "haptic": haptic(m["kind"] as? String) + case "unread": if let n = m["n"] as? Int { Badge.set(n) } default: break } } diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index f22c58b..6a7148b 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -227,6 +227,7 @@ final class Playback: NSObject { onTime?(secs, duration) updateElapsed(secs) if abs(secs - lastSaved) > 10 { savePosition() } + Badge.refreshIfStale() // Heard, not just started: the same 90% the page uses, so an item does not leave Unread // the moment it is pressed. The page does the same while it runs; this is for when it // does not. diff --git a/ios/project.yml b/ios/project.yml index cc54dc5..e6b7407 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -25,7 +25,7 @@ targets: properties: CFBundleDisplayName: iPX CFBundleShortVersionString: $(MARKETING_VERSION) - CFBundleVersion: "9" + CFBundleVersion: "10" # 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. UIBackgroundModes: [audio, fetch] diff --git a/tests/native-bridge.ts b/tests/native-bridge.ts index 387984a..bbdc691 100644 --- a/tests/native-bridge.ts +++ b/tests/native-bridge.ts @@ -175,6 +175,11 @@ since(); vm.runInContext('toast("Unable to play", true)', ctx); ok(last('haptic')?.kind === 'warn', 'an error was not felt'); +// The icon's count (#421): the page's own, sent when it changes. +since(); +vm.runInContext('hostBadge(7); hostBadge(7); hostBadge(3)', ctx); +ok(posted.filter(m => m.t === 'unread').map(m => m.n).join() === '7,3', 'the app was not told the unread count once per change'); + 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); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 57873c5..d05ea29 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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). - 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). diff --git a/web/src/feeds.ts b/web/src/feeds.ts index e10cfda..3f22106 100644 --- a/web/src/feeds.ts +++ b/web/src/feeds.ts @@ -108,6 +108,7 @@ function renderFeeds(){ for(const img of $$('img',list)){ const k=img.outerHTML; if(!drawn.has(k)) drawn.set(k,[]); drawn.get(k)!.push(img); } list.innerHTML=''; const unreadAll=unreadInAll(); + hostBadge(unreadAll); const places=document.createElement('div'); places.className='places'; for(const [id,v] of Object.entries(VIEWS)){ diff --git a/web/src/native.ts b/web/src/native.ts index b0782e1..f1aad32 100644 --- a/web/src/native.ts +++ b/web/src/native.ts @@ -25,6 +25,15 @@ const ipxHost: ((m: any) => void) | null = (() => { if (ipxHost) installNativePlayback(); +/// All Subscriptions' unread count, for the app's icon (#421), each time the list is drawn with a +/// new one: items read in the page and new ones arriving set it at once, where the app's own fetch +/// waited for the app to come forward again. +let badgeSent=-1; +function hostBadge(n: number){ + // As haptic: the list can be drawn before this file has declared what it uses. + try{ if(n!==badgeSent){ badgeSent=n; ipxHost?.({t:'unread', n}); } }catch{} +} + /// 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.