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

@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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). - 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). - `/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). - 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).

View File

@@ -99,7 +99,10 @@ async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64) -
.header("apns-push-type", "alert") .header("apns-push-type", "alert")
// Not urgent, so iOS may hold it to save the battery. // Not urgent, so iOS may hold it to save the battery.
.header("apns-priority", "5") .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() .send()
.await .await
.context("reaching Apple's push service")?; .context("reaching Apple's push service")?;

2
ios/.gitignore vendored
View File

@@ -4,3 +4,5 @@ DerivedData/
Resources/Info.plist Resources/Info.plist
.envrc .envrc
Resources/iPX.entitlements Resources/iPX.entitlements
Widgets/Info.plist
Widgets/iPXWidgets.entitlements

View File

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

View File

@@ -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/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/CookieBridge.swift` | the web view's cookies into `HTTPCookieStorage.shared`, for the player |
| `Sources/Server.swift` | which server | | `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 ## The messages

View File

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

30
ios/Shared/Snapshot.swift Normal file
View File

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

View File

@@ -24,6 +24,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
Push.token = deviceToken.map { String(format: "%02x", $0) }.joined() 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, func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) { didFailToRegisterForRemoteNotificationsWithError error: Error) {
NSLog("ipx: push registration: %@", error.localizedDescription) 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). /// The page's own count, sent as its list changes (#421).
@MainActor static func set(_ n: Int) { @MainActor static func set(_ n: Int) {
lastSet = Date() lastSet = Date()
WidgetFeed.update { $0.unread = max(0, n) }
Task { try? await UNUserNotificationCenter.current().setBadgeCount(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. /// with a feed list: an old number is better than a wrong one.
@discardableResult @discardableResult
static func refresh() async -> Bool { 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) var req = URLRequest(url: url)
req.timeoutInterval = 20 req.timeoutInterval = 20
req.setValue("application/json", forHTTPHeaderField: "Accept") req.setValue("application/json", forHTTPHeaderField: "Accept")
@@ -72,9 +80,8 @@ enum Badge {
cfg.httpCookieStorage = .shared cfg.httpCookieStorage = .shared
guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req), guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200, (resp as? HTTPURLResponse)?.statusCode == 200,
let n = unread(inAll: data) else { return false } let n = unread(inAll: data) else { return nil }
await MainActor.run { set(n) } return n
return true
} }
// MARK: - while the app is closed // 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. /// and a car's buttons reach. Knows nothing of the web view; the bridge drives it.
@MainActor @MainActor
final class Playback: NSObject { 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. /// 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 url: URL
var enc: Int var enc: Int
var feedId: String var feedId: String
@@ -38,6 +42,9 @@ final class Playback: NSObject {
private var statusObservation: NSKeyValueObservation? private var statusObservation: NSKeyValueObservation?
private var endObserver: NSObjectProtocol? private var endObserver: NSObjectProtocol?
private var lastSaved: Double = 0 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 markedRead = false
private var rate: Float = 1 private var rate: Float = 1
private var volume: Float = 1 private var volume: Float = 1
@@ -52,7 +59,7 @@ final class Playback: NSObject {
return d return d
} }
override init() { private override init() {
super.init() super.init()
// spokenAudio is what tells the system this is speech, so navigation's voice ducks it // 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. // 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 // MARK: - what the page asks for
func load(_ item: Item) { func load(_ item: Item, startAt: Double? = nil) {
stop(notify: false) stop(notify: false)
self.item = item self.item = item
self.startAt = startAt
markedRead = false markedRead = false
lastSaved = 0 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 // 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. // 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 } guard let self else { return }
switch pi.status { switch pi.status {
case .readyToPlay: case .readyToPlay:
if let at = self.startAt {
self.startAt = nil
self.seek(to: at)
}
if let d = self.duration { self.onMeta?(d) } if let d = self.duration { self.onMeta?(d) }
case .failed: case .failed:
// A file that will not play, or a session that went while it played: only the // 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) player.playImmediately(atRate: rate)
onState?(true) onState?(true)
updateNowPlaying() 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() { func pause() {
@@ -135,6 +168,7 @@ final class Playback: NSObject {
onState?(false) onState?(false)
savePosition() savePosition()
updateNowPlaying() updateNowPlaying()
WidgetFeed.update { $0.playing = false }
} }
func seek(to secs: Double) { func seek(to secs: Double) {
@@ -200,6 +234,7 @@ final class Playback: NSObject {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
if notify { onState?(false) } 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 /// 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()) let secs = Int(player.currentTime().seconds.rounded())
guard secs > 0 else { return } guard secs > 0 else { return }
lastSaved = Double(secs) 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] var body: [String: Any] = ["secs": secs]
if let d = duration { body["duration"] = Int(d.rounded()) } 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. // 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() { private func didEnd() {
savePosition() 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() markRead()
onState?(false) onState?(false)
onEnded?() onEnded?()

View File

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

View File

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

115
ios/Widgets/Widgets.swift Normal file
View File

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

View File

@@ -19,16 +19,19 @@ targets:
platform: iOS platform: iOS
sources: sources:
- Sources - Sources
- Shared
- Resources/Assets.xcassets - Resources/Assets.xcassets
dependencies:
- target: iPXWidgets
info: info:
path: Resources/Info.plist path: Resources/Info.plist
properties: properties:
CFBundleDisplayName: iPX CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "11" CFBundleVersion: "12"
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on # 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. # 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). # The unread badge's refresh while the app is closed (#421).
BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh] BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh]
# HTTPS only, so TestFlight need not ask the export-compliance question on every build. # HTTPS only, so TestFlight need not ask the export-compliance question on every build.
@@ -61,6 +64,8 @@ targets:
path: Resources/iPX.entitlements path: Resources/iPX.entitlements
properties: properties:
aps-environment: development 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: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
@@ -79,6 +84,34 @@ targets:
/usr/libexec/PlistBuddy -c "Add :WKAppBoundDomains: string localhost" "$TARGET_BUILD_DIR/$INFOPLIST_PATH" /usr/libexec/PlistBuddy -c "Add :WKAppBoundDomains: string localhost" "$TARGET_BUILD_DIR/$INFOPLIST_PATH"
fi 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: iPXTests:
type: bundle.unit-test type: bundle.unit-test
platform: iOS platform: iOS