#442: a car's pick told the page `replaced`, and the page closed its player bar, so leaving the car with the episode playing left nothing on the phone to pause it. And when the car launched the app, the page loaded later and was never told anything played. Now the app sends `nowPlaying` (the item, its place, playing or not) on a car pick, at the page's `ready`, and on coming forward, and the page's bar takes it up and drives the phone's player. `replaced` is still handled for builds before this one. #443: Shows listed every feed with items, blogs included. Now only feeds with a downloaded file, and an episode only with a downloaded audio file: /media/{id} answers 404 before the download. #444: tests/native-bridge.ts read its failure count before the CarPlay checks, so those could fail and still exit 0. The count is read last now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
422 lines
17 KiB
Swift
422 lines
17 KiB
Swift
import AVFoundation
|
|
import MediaPlayer
|
|
import UIKit
|
|
|
|
/// The phone's player: the one that plays on with the screen locked, and the one the lock screen
|
|
/// 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: Codable, Equatable {
|
|
var url: URL
|
|
var enc: Int
|
|
var feedId: String
|
|
var guid: String
|
|
var title: String
|
|
var feedTitle: String
|
|
var artwork: String?
|
|
var duration: Int?
|
|
}
|
|
|
|
var onTime: ((Double, Double?) -> Void)?
|
|
var onMeta: ((Double) -> Void)?
|
|
var onState: ((Bool) -> Void)?
|
|
var onEnded: (() -> Void)?
|
|
var onError: ((String) -> Void)?
|
|
/// The sleep timer ran out and playback paused.
|
|
var onSlept: (() -> Void)?
|
|
/// Something the page did not choose is loaded now: the car's pick (#420).
|
|
var onChosen: (() -> Void)?
|
|
|
|
private let session: URLSession = {
|
|
let cfg = URLSessionConfiguration.default
|
|
cfg.httpCookieStorage = .shared
|
|
cfg.timeoutIntervalForRequest = 20
|
|
return URLSession(configuration: cfg)
|
|
}()
|
|
private var player: AVPlayer?
|
|
private var item: Item?
|
|
private var timeObserver: Any?
|
|
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
|
|
private var sleepTimer: Timer?
|
|
/// When the sleep timer pauses playback, if it is set.
|
|
private(set) var sleepAt: Date?
|
|
|
|
var isPlaying: Bool { player?.timeControlStatus == .playing }
|
|
/// Playing, or about to once it has buffered: what a player bar's button should say.
|
|
var wantsToPlay: Bool { player.map { $0.timeControlStatus != .paused } ?? false }
|
|
/// What is loaded, for the car's lists to mark.
|
|
var current: Item? { item }
|
|
var currentTime: Double { player?.currentTime().seconds ?? 0 }
|
|
var duration: Double? {
|
|
guard let d = player?.currentItem?.duration.seconds, d.isFinite, d > 0 else { return nil }
|
|
return d
|
|
}
|
|
|
|
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.
|
|
do {
|
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
|
policy: .longFormAudio)
|
|
} catch {
|
|
NSLog("ipx: audio session: %@", error.localizedDescription)
|
|
}
|
|
wireRemoteCommands()
|
|
}
|
|
|
|
// MARK: - what the page asks for
|
|
|
|
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.
|
|
// The header option has no constant in the SDK; AVFoundation reads it by this name.
|
|
var options: [String: Any] = [
|
|
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
|
|
]
|
|
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
|
|
// One kept on the phone plays from there, connection or not (#425). The page asks for it
|
|
// as `/media/42?offline`; the path alone is what it was kept under.
|
|
let asset = Offline.shared.store.fileURL(for: item.url.path).map { AVURLAsset(url: $0) }
|
|
?? AVURLAsset(url: item.url, options: options)
|
|
let playerItem = AVPlayerItem(asset: asset)
|
|
let player = AVPlayer(playerItem: playerItem)
|
|
player.volume = volume
|
|
player.defaultRate = rate
|
|
self.player = player
|
|
|
|
// Where to start is the page's to decide, on loadedmetadata, so one piece of code owns it.
|
|
// Here only the length is reported.
|
|
statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
|
Task { @MainActor in
|
|
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
|
|
// second has something to do about it.
|
|
let msg = pi.error?.localizedDescription ?? "the file could not be played"
|
|
let status = await self.request("GET", "/api/me")
|
|
let expired = Credentials.isSet ? "Check the player token in Settings" : "Sign in again"
|
|
self.onError?(status == 401 || status == 403 ? expired : msg)
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
timeObserver = player.addPeriodicTimeObserver(
|
|
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main
|
|
) { [weak self] t in
|
|
MainActor.assumeIsolated { self?.tick(t.seconds) }
|
|
}
|
|
endObserver = NotificationCenter.default.addObserver(
|
|
forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main
|
|
) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.didEnd() }
|
|
}
|
|
updateNowPlaying()
|
|
}
|
|
|
|
func play() {
|
|
guard let player else { return }
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
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
|
|
}
|
|
|
|
/// An episode chosen away from the page, in the car (#420): played from where it got to, and
|
|
/// the page told before it plays, so its player bar shows this one, not the old one or none
|
|
/// (#442), and the state that follows reaches it.
|
|
func playChosen(_ item: Item, startAt: Double?) {
|
|
if item == self.item {
|
|
play()
|
|
return
|
|
}
|
|
load(item, startAt: startAt)
|
|
onChosen?()
|
|
play()
|
|
}
|
|
|
|
func pause() {
|
|
player?.pause()
|
|
onState?(false)
|
|
savePosition()
|
|
updateNowPlaying()
|
|
WidgetFeed.update { $0.playing = false }
|
|
}
|
|
|
|
func seek(to secs: Double) {
|
|
// Exact, not the default tolerance, which snaps to a keyframe minutes from where the
|
|
// scrubber was let go on a long episode.
|
|
player?.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600),
|
|
toleranceBefore: .zero, toleranceAfter: .zero)
|
|
updateNowPlaying()
|
|
}
|
|
|
|
func setRate(_ r: Float) {
|
|
rate = r
|
|
player?.defaultRate = r
|
|
// Setting rate on a paused player starts it, which changing the speed should not do.
|
|
if isPlaying { player?.rate = r }
|
|
updateNowPlaying()
|
|
}
|
|
|
|
func setVolume(_ v: Float) {
|
|
volume = v
|
|
player?.volume = v
|
|
}
|
|
|
|
/// The sleep timer (#429): pauses after `secs`, the last ten fading out; 0 turns it off. Kept
|
|
/// here, not in the page: with the screen locked the web view is frozen, and a timer of its own
|
|
/// would not fire until the app was opened again. This one fires because the app is playing.
|
|
func sleep(after secs: Double) {
|
|
sleepTimer?.invalidate()
|
|
sleepTimer = nil
|
|
sleepAt = nil
|
|
player?.volume = volume
|
|
guard secs > 0 else { return }
|
|
sleepAt = Date(timeIntervalSinceNow: secs)
|
|
sleepTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
|
MainActor.assumeIsolated { self?.sleepTick() }
|
|
}
|
|
}
|
|
|
|
private func sleepTick() {
|
|
guard let sleepAt else { return }
|
|
let left = sleepAt.timeIntervalSinceNow
|
|
// A fade, not a cut: a sudden stop wakes the person it was set for.
|
|
if left > 0 {
|
|
if left < 10 { player?.volume = volume * Float(left / 10) }
|
|
return
|
|
}
|
|
sleep(after: 0)
|
|
pause()
|
|
onSlept?()
|
|
}
|
|
|
|
func stop(notify: Bool = true) {
|
|
sleep(after: 0)
|
|
savePosition()
|
|
if let timeObserver { player?.removeTimeObserver(timeObserver) }
|
|
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
|
|
timeObserver = nil
|
|
endObserver = nil
|
|
statusObservation = nil
|
|
player?.pause()
|
|
player = nil
|
|
item = nil
|
|
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
|
|
/// own: a web view frozen in the background holds a time from minutes ago, and writing that
|
|
/// over where you got to is the stale write player.ts guards against.
|
|
func savePosition() {
|
|
guard let item, let player, player.currentItem?.status == .readyToPlay else { return }
|
|
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.
|
|
if item.url.path.contains("/deadair") { body["deadair"] = item.enc }
|
|
let path = entryPath(item, "position")
|
|
// Not arrived, kept for when the connection is back: offline, the ten-second retry would
|
|
// only fail again, and the place reached on the last pause would be lost.
|
|
Task { if await request("POST", path, body) == nil { Outbox.add(path: path, json: body) } }
|
|
}
|
|
|
|
// MARK: - as it plays
|
|
|
|
private func tick(_ secs: Double) {
|
|
guard secs.isFinite else { return }
|
|
onTime?(secs, duration)
|
|
updateElapsed(secs)
|
|
if abs(secs - lastSaved) > 10 { savePosition() }
|
|
Badge.refreshIfStale()
|
|
// Heard, not just started: the same 90% the page uses, so an item does not leave Unread
|
|
// the moment it is pressed. The page does the same while it runs; this is for when it
|
|
// does not.
|
|
if let d = duration, secs / d >= 0.9 { markRead() }
|
|
}
|
|
|
|
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?()
|
|
updateNowPlaying()
|
|
}
|
|
|
|
private func markRead() {
|
|
guard !markedRead, let item else { return }
|
|
markedRead = true
|
|
let path = entryPath(item, "flags")
|
|
Task {
|
|
if await request("POST", path, ["read": true]) == nil { Outbox.add(path: path, json: ["read": true]) }
|
|
await Badge.refresh()
|
|
}
|
|
}
|
|
|
|
// MARK: - the lock screen and the car
|
|
|
|
private func wireRemoteCommands() {
|
|
let c = MPRemoteCommandCenter.shared()
|
|
c.playCommand.addTarget { [weak self] _ in self?.play(); return .success }
|
|
c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
|
|
c.togglePlayPauseCommand.addTarget { [weak self] _ in
|
|
guard let self else { return .commandFailed }
|
|
self.isPlaying ? self.pause() : self.play()
|
|
return .success
|
|
}
|
|
// The page's 15 back and 30 forward, so a steering wheel and the keyboard agree.
|
|
c.skipBackwardCommand.preferredIntervals = [15]
|
|
c.skipForwardCommand.preferredIntervals = [30]
|
|
c.skipBackwardCommand.addTarget { [weak self] _ in
|
|
guard let self else { return .commandFailed }
|
|
self.seek(to: self.currentTime - 15)
|
|
return .success
|
|
}
|
|
c.skipForwardCommand.addTarget { [weak self] _ in
|
|
guard let self else { return .commandFailed }
|
|
self.seek(to: self.currentTime + 30)
|
|
return .success
|
|
}
|
|
c.changePlaybackPositionCommand.addTarget { [weak self] ev in
|
|
guard let self, let e = ev as? MPChangePlaybackPositionCommandEvent else {
|
|
return .commandFailed
|
|
}
|
|
self.seek(to: e.positionTime)
|
|
return .success
|
|
}
|
|
}
|
|
|
|
private func updateNowPlaying() {
|
|
guard let item else { return }
|
|
var info: [String: Any] = [
|
|
MPMediaItemPropertyTitle: item.title,
|
|
MPMediaItemPropertyArtist: item.feedTitle,
|
|
MPMediaItemPropertyAlbumTitle: item.feedTitle,
|
|
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
|
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? rate : 0,
|
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
|
]
|
|
if let d = duration ?? item.duration.map(Double.init) {
|
|
info[MPMediaItemPropertyPlaybackDuration] = d
|
|
}
|
|
let centre = MPNowPlayingInfoCenter.default()
|
|
centre.nowPlayingInfo = info
|
|
|
|
guard let art = item.artwork, let url = Server.resolveForPlayer(art) else { return }
|
|
Task {
|
|
var artReq = URLRequest(url: url)
|
|
Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) }
|
|
guard let (data, _) = try? await session.data(for: artReq),
|
|
let image = UIImage(data: data), self.item?.guid == item.guid else { return }
|
|
// Read back, not `info`: by the time the picture arrives that elapsed time is stale,
|
|
// and writing it would jerk the progress bar back.
|
|
var latest = centre.nowPlayingInfo ?? [:]
|
|
latest[MPMediaItemPropertyArtwork] = Self.artwork(image)
|
|
centre.nowPlayingInfo = latest
|
|
}
|
|
}
|
|
|
|
/// Made outside the main actor: the system asks for the picture on a thread of its own.
|
|
nonisolated private static func artwork(_ image: UIImage) -> MPMediaItemArtwork {
|
|
MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
|
}
|
|
|
|
/// The clock alone, each tick: rewriting the whole dictionary that often makes a car's
|
|
/// progress bar stutter.
|
|
private func updateElapsed(_ secs: Double) {
|
|
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
|
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs
|
|
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? rate : 0
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
}
|
|
|
|
// MARK: - ipx
|
|
|
|
private func entryPath(_ item: Item, _ what: String) -> String {
|
|
let esc = { (s: String) in s.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? s }
|
|
return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)"
|
|
}
|
|
|
|
/// One call to ipx, signed in by the credentials or else the web view's cookies; its status, or nil if it never
|
|
/// arrived. A position that does not save is saved again ten seconds on.
|
|
@discardableResult
|
|
private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? {
|
|
guard let url = Server.resolveForPlayer(path) else { return nil }
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = method
|
|
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
|
if let json {
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try? JSONSerialization.data(withJSONObject: json)
|
|
}
|
|
guard let (_, resp) = try? await session.data(for: req) else { return nil }
|
|
return (resp as? HTTPURLResponse)?.statusCode
|
|
}
|
|
}
|