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

@@ -24,6 +24,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
Push.token = deviceToken.map { String(format: "%02x", $0) }.joined()
}
/// ipx's count, arriving with the app closed (#433): `content-available` wakes the app for a
/// moment, which the badge alone does not, so the widget's count follows the icon's (#428).
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard let n = (userInfo["aps"] as? [String: Any])?["badge"] as? Int else {
completionHandler(.noData)
return
}
WidgetFeed.update { $0.unread = n }
completionHandler(.newData)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
NSLog("ipx: push registration: %@", error.localizedDescription)

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

109
ios/Sources/Intents.swift Normal file
View File

@@ -0,0 +1,109 @@
import AppIntents
// Siri and Shortcuts (#427). Each is an App Intent, and IPXShortcuts below gives the common ones
// phrases that work with nothing set up: "Resume iPX", "Pause iPX", "How many unread in iPX".
// The playback ones are AudioPlaybackIntents, which iOS runs in the app and lets start audio
// from the background, so they work with the phone locked and the app closed.
struct ResumeIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Resume Listening"
static let description = IntentDescription(
"Carries on with what iPX was playing, or the last episode, from where you left off.")
@MainActor
func perform() async throws -> some IntentResult {
guard Playback.shared.resumeLast() else { throw NothingToResume() }
return .result()
}
}
/// What Siri says when there is no episode to carry on with.
struct NothingToResume: Error, CustomLocalizedStringResourceConvertible {
var localizedStringResource: LocalizedStringResource {
"There is nothing to resume. Play an episode in iPX first."
}
}
struct PauseIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Pause"
static let description = IntentDescription("Pauses what iPX is playing.")
@MainActor
func perform() async throws -> some IntentResult {
Playback.shared.pause()
return .result()
}
}
struct SkipForwardIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Skip Forward"
static let description = IntentDescription("Skips 30 seconds ahead, as the player's button does.")
@MainActor
func perform() async throws -> some IntentResult {
Playback.shared.seek(to: Playback.shared.currentTime + 30)
return .result()
}
}
struct SkipBackIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Skip Back"
static let description = IntentDescription("Goes back 15 seconds, as the player's button does.")
@MainActor
func perform() async throws -> some IntentResult {
Playback.shared.seek(to: Playback.shared.currentTime - 15)
return .result()
}
}
struct SleepTimerIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Set Sleep Timer"
static let description = IntentDescription(
"Pauses iPX after a number of minutes, fading out over the last ten seconds. Zero turns it off.")
@Parameter(title: "Minutes", default: 30, inclusiveRange: (0, 600),
requestValueDialog: "For how many minutes?")
var minutes: Int
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
Playback.shared.sleep(after: Double(minutes) * 60)
return .result(dialog: minutes == 0 ? "The sleep timer is off." : "iPX will pause in \(minutes) minutes.")
}
}
struct UnreadIntent: AppIntent {
static let title: LocalizedStringResource = "Unread Count"
static let description = IntentDescription("How many items are unread in All Subscriptions.")
func perform() async throws -> some IntentResult & ReturnsValue<Int> & ProvidesDialog {
guard let n = await Badge.fetch() else {
return .result(value: 0, dialog: "iPX could not reach your server.")
}
await MainActor.run { Badge.set(n) }
return .result(value: n, dialog: n == 0 ? "Nothing is unread." : "\(n) unread in All Subscriptions.")
}
}
struct IPXShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(intent: ResumeIntent(),
phrases: ["Resume \(.applicationName)", "Play \(.applicationName)",
"Carry on listening in \(.applicationName)"],
shortTitle: "Resume", systemImageName: "play.fill")
AppShortcut(intent: PauseIntent(), phrases: ["Pause \(.applicationName)"],
shortTitle: "Pause", systemImageName: "pause.fill")
AppShortcut(intent: SkipForwardIntent(),
phrases: ["Skip forward in \(.applicationName)", "Skip ahead in \(.applicationName)"],
shortTitle: "Skip Forward", systemImageName: "goforward.30")
AppShortcut(intent: SkipBackIntent(), phrases: ["Skip back in \(.applicationName)"],
shortTitle: "Skip Back", systemImageName: "gobackward.15")
AppShortcut(intent: SleepTimerIntent(),
phrases: ["Set a sleep timer in \(.applicationName)", "\(.applicationName) sleep timer"],
shortTitle: "Sleep Timer", systemImageName: "moon.zzz")
AppShortcut(intent: UnreadIntent(),
phrases: ["How many unread in \(.applicationName)", "What's new in \(.applicationName)"],
shortTitle: "Unread", systemImageName: "tray.full")
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
/// The episode the player last had and where it got to, kept across launches, so "Resume iPX"
/// (#427) and the widget's button carry on with it when the app was closed and no page is loaded
/// to say what to play.
enum LastPlayed {
private static let itemKey = "ipx.last.item"
private static let positionKey = "ipx.last.position"
static var defaults = UserDefaults.standard
static var item: Playback.Item? {
get { defaults.data(forKey: itemKey).flatMap { try? JSONDecoder().decode(Playback.Item.self, from: $0) } }
set {
// A different episode starts from where the page put it, not the last one's place.
if newValue != item { defaults.removeObject(forKey: positionKey) }
defaults.set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: itemKey)
}
}
static var position: Double? {
get { defaults.object(forKey: positionKey) as? Double }
set { defaults.set(newValue, forKey: positionKey) }
}
}

View File

@@ -6,8 +6,12 @@ import UIKit
/// and a car's buttons reach. Knows nothing of the web view; the bridge drives it.
@MainActor
final class Playback: NSObject {
/// One for the app: the page, Siri, Shortcuts and the widget's button all drive it (#427), and
/// the last three with no page loaded at all.
static let shared = Playback()
/// What is loaded: enough for the lock screen, and to save a position without asking the page.
struct Item {
struct Item: Codable, Equatable {
var url: URL
var enc: Int
var feedId: String
@@ -38,6 +42,9 @@ final class Playback: NSObject {
private var statusObservation: NSKeyValueObservation?
private var endObserver: NSObjectProtocol?
private var lastSaved: Double = 0
/// Where to start once the file is ready, for an episode resumed without the page (#427),
/// whose own seek on loadedmetadata is what places it otherwise.
private var startAt: Double?
private var markedRead = false
private var rate: Float = 1
private var volume: Float = 1
@@ -52,7 +59,7 @@ final class Playback: NSObject {
return d
}
override init() {
private override init() {
super.init()
// spokenAudio is what tells the system this is speech, so navigation's voice ducks it
// properly, and longFormAudio is what routes it as a podcast does.
@@ -67,11 +74,20 @@ final class Playback: NSObject {
// MARK: - what the page asks for
func load(_ item: Item) {
func load(_ item: Item, startAt: Double? = nil) {
stop(notify: false)
self.item = item
self.startAt = startAt
markedRead = false
lastSaved = 0
LastPlayed.item = item
WidgetFeed.update { s in
s.title = item.title
s.feedTitle = item.feedTitle
s.duration = item.duration.map(Double.init)
s.position = startAt
s.playing = false
}
// An AVURLAsset does not read the shared cookie store by itself, and the cookie is how
// ipx knows whose file this is, unless the credentials are set, which do not expire.
@@ -97,6 +113,10 @@ final class Playback: NSObject {
guard let self else { return }
switch pi.status {
case .readyToPlay:
if let at = self.startAt {
self.startAt = nil
self.seek(to: at)
}
if let d = self.duration { self.onMeta?(d) }
case .failed:
// A file that will not play, or a session that went while it played: only the
@@ -128,6 +148,19 @@ final class Playback: NSObject {
player.playImmediately(atRate: rate)
onState?(true)
updateNowPlaying()
WidgetFeed.update { $0.playing = true }
}
/// Carries on: what is loaded, or else the last episode, from where it got to (#427). False
/// when there is nothing to resume.
@discardableResult
func resumeLast() -> Bool {
if player == nil {
guard let last = LastPlayed.item else { return false }
load(last, startAt: LastPlayed.position)
}
play()
return true
}
func pause() {
@@ -135,6 +168,7 @@ final class Playback: NSObject {
onState?(false)
savePosition()
updateNowPlaying()
WidgetFeed.update { $0.playing = false }
}
func seek(to secs: Double) {
@@ -200,6 +234,7 @@ final class Playback: NSObject {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
if notify { onState?(false) }
WidgetFeed.update { $0.playing = false }
}
/// Where we are, saved from this clock. The page asks for it too, and sends no time of its
@@ -210,6 +245,11 @@ final class Playback: NSObject {
let secs = Int(player.currentTime().seconds.rounded())
guard secs > 0 else { return }
lastSaved = Double(secs)
LastPlayed.position = Double(secs)
WidgetFeed.update(reload: false) { s in
s.position = Double(secs)
if let d = duration { s.duration = d }
}
var body: [String: Any] = ["secs": secs]
if let d = duration { body["duration"] = Int(d.rounded()) }
// DeadAir's copy runs on a shorter clock; the server has to know which one this is.
@@ -236,6 +276,14 @@ final class Playback: NSObject {
private func didEnd() {
savePosition()
// Finished: nothing to resume, and the widget says so.
LastPlayed.item = nil
WidgetFeed.update { s in
s.title = nil
s.feedTitle = nil
s.position = nil
s.playing = false
}
markRead()
onState?(false)
onEnded?()

View File

@@ -5,8 +5,7 @@ import WebKit
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
private var webView: WKWebView!
private var cookies: CookieBridge!
// Lazy, so it is made in viewDidLoad, on the main actor it belongs to.
private lazy var playback = Playback()
private var playback: Playback { .shared }
override func viewDidLoad() {
super.viewDidLoad()

View File

@@ -0,0 +1,16 @@
import WidgetKit
/// The app's side of the widget (#428): changes what it shows, and asks WidgetKit to draw it again
/// when something it shows has changed. Not for each position saved, every ten seconds: iOS gives
/// an app a few dozen redraws a day, so the place moves on at the next play, pause or count.
@MainActor
enum WidgetFeed {
static func update(reload: Bool = true, _ change: (inout Snapshot) -> Void) {
var s = Snapshot.load()
let was = s
change(&s)
guard s != was else { return }
s.save()
if reload { WidgetCenter.shared.reloadAllTimelines() }
}
}