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 playing now: the car's pick (#420). var onReplaced: (() -> 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 } /// 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, whose player bar would otherwise go on as the old one, saving its place and /// marking it read by this one's clock. func playChosen(_ item: Item, startAt: Double?) { if item == self.item { play() return } onReplaced?() load(item, startAt: startAt) 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 } }