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:
@@ -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?()
|
||||
|
||||
Reference in New Issue
Block a user