Files
ipx/ios/Sources/Playback.swift
Ray Slakinski ecc248307e iOS: episodes kept on the phone play with no connection (#425)
A WKWebView runs no service worker, so the page's Keep button (#412)
was not shown in the app and everything played from the server. Now the
page asks the app to keep the file (keep, unkeep, clearKept), and the
app answers with what it holds (kept, keepFailed) whether or not
anything plays. The app fetches with a background URLSession, so a
download goes on with the app closed, into Application Support (not
backed up; the files can be fetched again), and Playback plays from the
file when there is one. The DeadAir copy is kept under its own path, as
the browser keeps it. Places and finishes reached offline wait in an
outbox, newest per address, and go when the network is back.

Checked in the simulator against a local server: kept /media/42 and
/media/42/deadair, then with the server refusing /media, a fresh launch
played each from the phone and asked the server for nothing. The page
itself still needs a connection to open (#435).

Deploy the app (build 6) before the page: a new page in an old app
shows a Keep button nothing answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:40:01 -05:00

320 lines
13 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 {
/// What is loaded: enough for the lock screen, and to save a position without asking the page.
struct Item {
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)?
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
private var markedRead = false
private var rate: Float = 1
private var volume: Float = 1
var isPlaying: Bool { player?.timeControlStatus == .playing }
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
}
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) {
stop(notify: false)
self.item = item
markedRead = false
lastSaved = 0
// 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 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()
}
func pause() {
player?.pause()
onState?(false)
savePosition()
updateNowPlaying()
}
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
}
func stop(notify: Bool = true) {
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) }
}
/// 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)
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() }
// 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()
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
}
}