From f844cf349a5de8a0d02a32af2483a102562317cd Mon Sep 17 00:00:00 2001 From: Ray Slakinski <19767+rays@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:32:43 -0500 Subject: [PATCH] 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 --- crates/ipx/CHANGELOG.md | 2 +- crates/ipx/src/push.rs | 5 +- ios/.gitignore | 2 + ios/CHANGELOG.md | 2 + ios/README.md | 24 ++++++ ios/Shared/PlayPauseIntent.swift | 18 +++++ ios/Shared/Snapshot.swift | 30 ++++++++ ios/Sources/App.swift | 12 +++ ios/Sources/Badge.swift | 15 +++- ios/Sources/Intents.swift | 109 ++++++++++++++++++++++++++ ios/Sources/LastPlayed.swift | 24 ++++++ ios/Sources/Playback.swift | 54 ++++++++++++- ios/Sources/WebViewController.swift | 3 +- ios/Sources/WidgetFeed.swift | 16 ++++ ios/Tests/WidgetTests.swift | 48 ++++++++++++ ios/Widgets/Widgets.swift | 115 ++++++++++++++++++++++++++++ ios/project.yml | 37 ++++++++- 17 files changed, 503 insertions(+), 13 deletions(-) create mode 100644 ios/Shared/PlayPauseIntent.swift create mode 100644 ios/Shared/Snapshot.swift create mode 100644 ios/Sources/Intents.swift create mode 100644 ios/Sources/LastPlayed.swift create mode 100644 ios/Sources/WidgetFeed.swift create mode 100644 ios/Tests/WidgetTests.swift create mode 100644 ios/Widgets/Widgets.swift diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 57b4c77..3706065 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The iOS app's icon count stays right with the app closed: with `[push]` set to an APNs key, ipx sends each registered phone (`POST /api/push/devices`, removed with `DELETE`) its All Subscriptions unread count through Apple's push service, within a minute of a change (#433). +- The iOS app's icon count stays right with the app closed: with `[push]` set to an APNs key, ipx sends each registered phone (`POST /api/push/devices`, removed with `DELETE`) its All Subscriptions unread count through Apple's push service, within a minute of a change (#433). The push also wakes the app for a moment, for its widget to show the same count (#428). - A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412). - `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389). - The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384). diff --git a/crates/ipx/src/push.rs b/crates/ipx/src/push.rs index cbeda71..7950b82 100644 --- a/crates/ipx/src/push.rs +++ b/crates/ipx/src/push.rs @@ -99,7 +99,10 @@ async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64) - .header("apns-push-type", "alert") // Not urgent, so iOS may hold it to save the battery. .header("apns-priority", "5") - .json(&serde_json::json!({ "aps": { "badge": n } })) + // content-available wakes the app for a moment, which the badge alone does not, so its + // widget's count follows the icon's (#428). iOS rations those wakes; the badge is set + // whether or not one comes. + .json(&serde_json::json!({ "aps": { "badge": n, "content-available": 1 } })) .send() .await .context("reaching Apple's push service")?; diff --git a/ios/.gitignore b/ios/.gitignore index a051736..390f11c 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -4,3 +4,5 @@ DerivedData/ Resources/Info.plist .envrc Resources/iPX.entitlements +Widgets/Info.plist +Widgets/iPXWidgets.entitlements diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index afb3a2a..24fb688 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -12,5 +12,7 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1. - 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). +- 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). +- 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). diff --git a/ios/README.md b/ios/README.md index fc58bfc..0144c6c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -71,6 +71,30 @@ on the `sdf1.net` zone) blocks every request to it except a bearer-token one for | `Sources/Playback.swift` | `AVPlayer`, the audio session, now playing, the remote commands, saving positions | | `Sources/CookieBridge.swift` | the web view's cookies into `HTTPCookieStorage.shared`, for the player | | `Sources/Server.swift` | which server | +| `Sources/Intents.swift`, `Shared/PlayPauseIntent.swift` | Siri and Shortcuts (#427) | +| `Sources/LastPlayed.swift` | the last episode and its place, so Resume works with the app closed | +| `Widgets/`, `Shared/Snapshot.swift`, `Sources/WidgetFeed.swift` | the widget (#428), and what the app writes for it | + +### Siri and Shortcuts + +With nothing to set up, Siri answers "Resume iPX" (or "Play iPX"), "Pause iPX", "Skip forward +in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX"; each is also +an action in the Shortcuts app. The playback ones are `AudioPlaybackIntent`s, which iOS runs in the +app and lets start audio with the phone locked. Resume carries on with what is loaded, or else the +last episode from where it got to (`LastPlayed`), which plays without the page: opening the app +afterwards shows the page with nothing in its player, while the lock screen has it. + +### The widget + +Small and medium on the home screen, circular and rectangular on the lock screen: All +Subscriptions' unread count and the last episode, with a Resume or Pause button (the same +`PlayPauseIntent`). The widget has no sign-in and no player, so it draws what the app last wrote +to the App Group `group.net.sdf1.ipx` (`Snapshot`), and the app asks WidgetKit to redraw on a play, +a pause, a new episode or a new count, not on every position saved, since iOS rations redraws. +With the app closed, ipx's push (#433) carries `content-available`, which wakes the app for a +moment to give the widget the new count; iOS rations those wakes too, so the widget can trail the +icon. `Shared/` is built into both targets, with `WIDGET` set in the widget's, where the player +does not exist. ## The messages diff --git a/ios/Shared/PlayPauseIntent.swift b/ios/Shared/PlayPauseIntent.swift new file mode 100644 index 0000000..794f136 --- /dev/null +++ b/ios/Shared/PlayPauseIntent.swift @@ -0,0 +1,18 @@ +import AppIntents + +/// Play or pause (#427, #428): Siri, Shortcuts and the widget's button. An `AudioPlaybackIntent`, +/// so iOS runs it in the app, which may start audio from the background, wherever it is asked +/// from. Compiled into the widget too, which needs the type for its button but never runs it. +struct PlayPauseIntent: AudioPlaybackIntent { + static let title: LocalizedStringResource = "Play or Pause" + static let description = IntentDescription( + "Pauses what iPX is playing, or carries on with the last episode where you left off.") + + @MainActor + func perform() async throws -> some IntentResult { + #if !WIDGET + if Playback.shared.isPlaying { Playback.shared.pause() } else { Playback.shared.resumeLast() } + #endif + return .result() + } +} diff --git a/ios/Shared/Snapshot.swift b/ios/Shared/Snapshot.swift new file mode 100644 index 0000000..f6e9be7 --- /dev/null +++ b/ios/Shared/Snapshot.swift @@ -0,0 +1,30 @@ +import Foundation + +/// What the widget shows (#428): the unread count and the episode you were last on. Written by +/// the app into the App Group the two share, since the widget has no sign-in and no player of its +/// own; it shows what the app last knew. +struct Snapshot: Codable, Equatable { + var unread: Int? + var title: String? + var feedTitle: String? + var position: Double? + var duration: Double? + var playing = false + + static let group = "group.net.sdf1.ipx" + private static let key = "ipx.snapshot" + + static func load(from defaults: UserDefaults? = UserDefaults(suiteName: group)) -> Snapshot { + defaults?.data(forKey: key).flatMap { try? JSONDecoder().decode(Snapshot.self, from: $0) } ?? Snapshot() + } + + func save(to defaults: UserDefaults? = UserDefaults(suiteName: group)) { + if let data = try? JSONEncoder().encode(self) { defaults?.set(data, forKey: Self.key) } + } + + /// How far through, 0 to 1, when both are known. + var progress: Double? { + guard let position, let duration, duration > 0 else { return nil } + return min(1, max(0, position / duration)) + } +} diff --git a/ios/Sources/App.swift b/ios/Sources/App.swift index 2dc6cf7..e058226 100644 --- a/ios/Sources/App.swift +++ b/ios/Sources/App.swift @@ -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) diff --git a/ios/Sources/Badge.swift b/ios/Sources/Badge.swift index 9cf0e3b..bcbf95d 100644 --- a/ios/Sources/Badge.swift +++ b/ios/Sources/Badge.swift @@ -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 diff --git a/ios/Sources/Intents.swift b/ios/Sources/Intents.swift new file mode 100644 index 0000000..fef6695 --- /dev/null +++ b/ios/Sources/Intents.swift @@ -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 & 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") + } +} diff --git a/ios/Sources/LastPlayed.swift b/ios/Sources/LastPlayed.swift new file mode 100644 index 0000000..c66bffd --- /dev/null +++ b/ios/Sources/LastPlayed.swift @@ -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) } + } +} diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index 6a7148b..56a889f 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -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?() diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index 513460d..17ca1dd 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -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() diff --git a/ios/Sources/WidgetFeed.swift b/ios/Sources/WidgetFeed.swift new file mode 100644 index 0000000..9f5dd63 --- /dev/null +++ b/ios/Sources/WidgetFeed.swift @@ -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() } + } +} diff --git a/ios/Tests/WidgetTests.swift b/ios/Tests/WidgetTests.swift new file mode 100644 index 0000000..904b518 --- /dev/null +++ b/ios/Tests/WidgetTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import iPX + +final class WidgetTests: XCTestCase { + private var defaults: UserDefaults! + + override func setUp() { + defaults = UserDefaults(suiteName: "ipx.tests.\(UUID().uuidString)") + LastPlayed.defaults = defaults + } + + func testTheWidgetReadsBackWhatTheAppWrote() { + var s = Snapshot() + s.unread = 7 + s.title = "An Episode" + s.position = 450 + s.duration = 1800 + s.playing = true + s.save(to: defaults) + XCTAssertEqual(Snapshot.load(from: defaults), s) + XCTAssertEqual(s.progress, 0.25) + } + + func testNothingWrittenYetIsAnEmptyWidget() { + XCTAssertEqual(Snapshot.load(from: defaults), Snapshot()) + XCTAssertNil(Snapshot().progress, "no bar without a length") + } + + private func item(_ guid: String) -> Playback.Item { + Playback.Item(url: URL(string: "https://x/media/1")!, enc: 1, feedId: "f", guid: guid, + title: "T", feedTitle: "F", artwork: nil, duration: 60) + } + + func testResumeCarriesOnFromTheLastPlace() { + LastPlayed.item = item("a") + LastPlayed.position = 42 + LastPlayed.item = item("a") + XCTAssertEqual(LastPlayed.position, 42, "loading the same episode again keeps its place") + } + + func testAnotherEpisodeDoesNotInheritThePlace() { + LastPlayed.item = item("a") + LastPlayed.position = 42 + LastPlayed.item = item("b") + XCTAssertNil(LastPlayed.position) + XCTAssertEqual(LastPlayed.item?.guid, "b") + } +} diff --git a/ios/Widgets/Widgets.swift b/ios/Widgets/Widgets.swift new file mode 100644 index 0000000..3307916 --- /dev/null +++ b/ios/Widgets/Widgets.swift @@ -0,0 +1,115 @@ +import AppIntents +import SwiftUI +import WidgetKit + +/// The home screen and lock screen widget (#428): All Subscriptions' unread count, and the episode +/// you were last on with a button to carry on or pause. It draws what the app last wrote to their +/// shared App Group (`Snapshot`); the app asks for a redraw when that changes. +@main +struct IPXWidgets: WidgetBundle { + var body: some Widget { + IPXWidget() + } +} + +struct IPXWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "net.sdf1.ipx.widget", provider: Provider()) { entry in + WidgetView(snap: entry.snap) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("iPX") + .description("What is unread, and the episode you were listening to.") + .supportedFamilies([.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular]) + } +} + +struct Entry: TimelineEntry { + let date: Date + let snap: Snapshot +} + +struct Provider: TimelineProvider { + private static let sample = Snapshot(unread: 12, title: "An episode", feedTitle: "A show", + position: 600, duration: 1800) + + func placeholder(in context: Context) -> Entry { Entry(date: .now, snap: Self.sample) } + + func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) { + completion(Entry(date: .now, snap: context.isPreview ? Self.sample : Snapshot.load())) + } + + /// One entry, kept until the app says something changed: the widget has nothing of its own + /// to look at. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [Entry(date: .now, snap: Snapshot.load())], policy: .never)) + } +} + +struct WidgetView: View { + @Environment(\.widgetFamily) private var family + let snap: Snapshot + + var body: some View { + switch family { + case .accessoryCircular: + VStack(spacing: 0) { + Text(count).font(.title3.weight(.semibold)).minimumScaleFactor(0.5) + Text("unread").font(.caption2) + } + case .accessoryRectangular: + VStack(alignment: .leading) { + Text("\(count) unread").font(.headline) + if let title = snap.title { Text(title).font(.caption).lineLimit(2) } + } + case .systemMedium: + HStack(spacing: 16) { + unread + Divider() + episode + } + default: + VStack(alignment: .leading) { + unread + Spacer(minLength: 4) + if let title = snap.title { + Text(title).font(.caption).lineLimit(2).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var count: String { + guard let n = snap.unread else { return "–" } + return n > 999 ? "999+" : "\(n)" + } + + private var unread: some View { + VStack(alignment: .leading, spacing: 0) { + Text(count).font(.system(size: 40, weight: .semibold, design: .rounded)) + .minimumScaleFactor(0.5).lineLimit(1) + Text("unread").font(.caption).foregroundStyle(.secondary) + } + } + + @ViewBuilder private var episode: some View { + if let title = snap.title { + VStack(alignment: .leading, spacing: 6) { + Text(snap.feedTitle ?? "").font(.caption2).foregroundStyle(.secondary).lineLimit(1) + Text(title).font(.subheadline.weight(.semibold)).lineLimit(2) + if let p = snap.progress { ProgressView(value: p).tint(.orange) } + Button(intent: PlayPauseIntent()) { + Label(snap.playing ? "Pause" : "Resume", + systemImage: snap.playing ? "pause.fill" : "play.fill") + .font(.caption.weight(.semibold)) + } + .tint(.orange) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("Nothing playing").font(.subheadline).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/ios/project.yml b/ios/project.yml index d639e97..40d64ce 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -19,16 +19,19 @@ targets: platform: iOS sources: - Sources + - Shared - Resources/Assets.xcassets + dependencies: + - target: iPXWidgets info: path: Resources/Info.plist properties: CFBundleDisplayName: iPX CFBundleShortVersionString: $(MARKETING_VERSION) - CFBundleVersion: "11" + CFBundleVersion: "12" # 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] + UIBackgroundModes: [audio, fetch, remote-notification] # The unread badge's refresh while the app is closed (#421). BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh] # HTTPS only, so TestFlight need not ask the export-compliance question on every build. @@ -61,6 +64,8 @@ targets: path: Resources/iPX.entitlements properties: aps-environment: development + # Shared with the widget (#428), which reads what the app last knew from it. + com.apple.security.application-groups: [group.net.sdf1.ipx] settings: base: PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx @@ -79,6 +84,34 @@ targets: /usr/libexec/PlistBuddy -c "Add :WKAppBoundDomains: string localhost" "$TARGET_BUILD_DIR/$INFOPLIST_PATH" fi + # The widget (#428): the unread count and the last episode, from what the app writes to the App + # Group. Its build number has to match the app's, or App Store Connect refuses the upload. + iPXWidgets: + type: app-extension + platform: iOS + sources: + - Widgets + - Shared + info: + path: Widgets/Info.plist + properties: + CFBundleDisplayName: iPX + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: "12" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + entitlements: + path: Widgets/iPXWidgets.entitlements + properties: + com.apple.security.application-groups: [group.net.sdf1.ipx] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx.widgets + MARKETING_VERSION: "0.1.0" + DEVELOPMENT_TEAM: 8743L8D729 + # Shared/ is built here too, where the app's player does not exist. + SWIFT_ACTIVE_COMPILATION_CONDITIONS: $(inherited) WIDGET + iPXTests: type: bundle.unit-test platform: iOS