iOS: Siri and Shortcuts, and a widget (#427, #428)

Siri: App Intents with phrases that need no setup: Resume, Pause,
Skip forward, Skip back, Set a sleep timer, How many unread. The
playback ones are AudioPlaybackIntents, run in the app and allowed to
start audio from the background. Playback is now one shared instance
(Playback.shared) so Siri and the widget reach it with no page loaded.
It remembers the last episode and its place (LastPlayed), so Resume
works with the app closed; the page, opened afterwards, does not know
about an episode started that way.

Widget: small and medium on the home screen, circular and rectangular
on the lock screen. It shows the unread count and the last episode,
with a Resume or Pause button (PlayPauseIntent, shared with Siri). It
draws what the app writes to the App Group group.net.sdf1.ipx. The app
asks for a redraw on play, pause, a new episode or a new count, not on
every position, since iOS rations redraws. Shared/ builds into both
targets, with WIDGET set in the widget's, where the player does not
exist.

ipx's push now carries content-available, which wakes the closed app
for a moment to give the widget the count the icon just got (#433).

Tested: unit tests for the snapshot and the remembered place, 19 in
all; in the simulator, playing an episode wrote the snapshot to the
App Group. Not testable there: Siri, and the widget drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 19:32:43 -05:00
parent 8f888ae10e
commit f844cf349a
17 changed files with 503 additions and 13 deletions

View File

@@ -45,6 +45,7 @@ enum Badge {
/// The page's own count, sent as its list changes (#421).
@MainActor static func set(_ n: Int) {
lastSet = Date()
WidgetFeed.update { $0.unread = max(0, n) }
Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) }
}
@@ -63,7 +64,14 @@ enum Badge {
/// with a feed list: an old number is better than a wrong one.
@discardableResult
static func refresh() async -> Bool {
guard let url = Server.resolveForPlayer("/api/feeds") else { return false }
guard let n = await fetch() else { return false }
await MainActor.run { set(n) }
return true
}
/// All Subscriptions' unread count from ipx, or nil when it cannot be had.
static func fetch() async -> Int? {
guard let url = Server.resolveForPlayer("/api/feeds") else { return nil }
var req = URLRequest(url: url)
req.timeoutInterval = 20
req.setValue("application/json", forHTTPHeaderField: "Accept")
@@ -72,9 +80,8 @@ enum Badge {
cfg.httpCookieStorage = .shared
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 }
await MainActor.run { set(n) }
return true
let n = unread(inAll: data) else { return nil }
return n
}
// MARK: - while the app is closed