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:
2
ios/.gitignore
vendored
2
ios/.gitignore
vendored
@@ -4,3 +4,5 @@ DerivedData/
|
||||
Resources/Info.plist
|
||||
.envrc
|
||||
Resources/iPX.entitlements
|
||||
Widgets/Info.plist
|
||||
Widgets/iPXWidgets.entitlements
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
18
ios/Shared/PlayPauseIntent.swift
Normal file
18
ios/Shared/PlayPauseIntent.swift
Normal 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
30
ios/Shared/Snapshot.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
109
ios/Sources/Intents.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
24
ios/Sources/LastPlayed.swift
Normal file
24
ios/Sources/LastPlayed.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
@@ -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?()
|
||||
|
||||
@@ -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()
|
||||
|
||||
16
ios/Sources/WidgetFeed.swift
Normal file
16
ios/Sources/WidgetFeed.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
48
ios/Tests/WidgetTests.swift
Normal file
48
ios/Tests/WidgetTests.swift
Normal 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
115
ios/Widgets/Widgets.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user