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 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 17:18:43 -05:00
parent b455c1ec8c
commit e6414941ab
10 changed files with 39 additions and 3 deletions

View File

@@ -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).

View File

@@ -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) |

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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]