import AVFoundation import Foundation import MediaPlayer import UIKit /// The host's player: the one CarPlay and a car's own buttons can reach, and the one that keeps /// going when the screen locks. Knows nothing about the web view -- the bridge drives it, and so /// will a CarPlay scene. @MainActor final class Playback: NSObject, ObservableObject { /// What a view needs to draw the bar. The closures below stay as they are: the page is not /// a SwiftUI view and the bridge still speaks to it by callback. @Published private(set) var nowPlaying: Item? @Published private(set) var elapsed: Double = 0 @Published private(set) var playing = false @Published private(set) var artwork: UIImage? /// The length once it is known, falling back to what the feed claimed. @Published private(set) var length: Double? /// What is loaded, enough to keep the car's screen right and to save a position without /// asking anyone. struct Item { var url: URL var feedId: String var guid: String var title: String var feedTitle: String var artwork: String? var position: Int var duration: Int? } /// Where playback state goes: the web page, through the bridge. A CarPlay scene would take /// the same callbacks. var onTime: ((Double, Double?) -> Void)? var onMeta: ((Double) -> Void)? var onState: ((Bool) -> Void)? var onEnded: (() -> Void)? var onError: ((String) -> Void)? private let cookies: CookieBridge private let api: API private var player: AVPlayer? private var item: Item? private var timeObserver: Any? private var statusObservation: NSKeyValueObservation? private var lastSaved: Double = 0 private var markedRead = false 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 } init(cookies: CookieBridge, api: API) { self.cookies = cookies self.api = api super.init() configureSession() wireRemoteCommands() } /// `.spokenAudio` is the podcast mode: it is what tells the system this is speech, so the /// right thing happens when navigation talks over it. private func configureSession() { do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, policy: .longFormAudio) } catch { NSLog("ipx: audio session: %@", error.localizedDescription) } } // MARK: - what the page asks for func load(_ item: Item) { stop(notify: false) self.item = item self.markedRead = false self.lastSaved = 0 self.nowPlaying = item self.elapsed = 0 self.length = item.duration.map(Double.init) self.artwork = nil if let art = item.artwork { Task { [weak self] in let image = await self?.api.image(art) await MainActor.run { self?.artwork = image } } } // The session cookie is how ipx knows who is asking, and an AVURLAsset does not read the // shared store on its own. let asset = AVURLAsset(url: item.url, options: [ AVURLAssetHTTPCookiesKey: cookies.cookies(for: item.url), ]) let playerItem = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: playerItem) // Seeking has to land where it was asked to: the default tolerance snaps to keyframes, // which on a long episode is minutes away from where you let go of the scrubber. player.automaticallyWaitsToMinimizeStalling = true self.player = player // Whose position this is belongs to the page, on loadedmetadata, so one piece of code // decides it. Here we only report that the length is known. statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in guard let self else { return } switch pi.status { case .readyToPlay: if let d = self.duration { self.onMeta?(d) } case .failed: let msg = pi.error?.localizedDescription ?? "the file could not be played" Task { // Tell one from the other: a file that will not play, and a session that // went while it was playing. Only the second has anything to do about it. let signedIn = (try? await self.api.me()) != nil await MainActor.run { self.onError?(signedIn ? msg : "Sign in again") } } default: break } } timeObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] t in self?.tick(t.seconds) } NotificationCenter.default.addObserver( self, selector: #selector(didEnd), name: .AVPlayerItemDidPlayToEndTime, object: playerItem) updateNowPlaying() } func play() { guard let player else { return } try? AVAudioSession.sharedInstance().setActive(true) player.play() playing = true onState?(true) updateNowPlaying() } func pause() { player?.pause() playing = false onState?(false) savePosition() updateNowPlaying() } func seek(to secs: Double) { guard let player else { return } player.seek(to: CMTime(seconds: max(0, secs), preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) updateNowPlaying() } func setRate(_ r: Float) { // Assigning to `rate` also starts playback, which is not what changing the speed of a // paused episode should do. player?.defaultRate = r if isPlaying { player?.rate = r } updateNowPlaying() } func setVolume(_ v: Float) { player?.volume = v } func stop(notify: Bool = true) { savePosition() if let timeObserver { player?.removeTimeObserver(timeObserver) } timeObserver = nil statusObservation = nil NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) player?.pause() player = nil item = nil nowPlaying = nil artwork = nil elapsed = 0 length = nil playing = false MPNowPlayingInfoCenter.default().nowPlayingInfo = nil try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) if notify { onState?(false) } } /// Where we are, saved. The page asks for this too, because its own beacon is ours now: it /// sends no time with the request, since the time a backgrounded web view holds may be /// minutes old. This clock is the only one worth writing. func savePosition() { guard let item, let player, player.currentItem?.status == .readyToPlay else { return } let secs = Int(player.currentTime().seconds.rounded()) // A file under a second rounds to nought, and "nought seconds in" is not worth writing. guard secs > 0 else { return } lastSaved = Double(secs) let dur = duration.map { Int($0.rounded()) } Task { try? await api.setPosition(secs: secs, duration: dur, feed: item.feedId, guid: item.guid) } } // MARK: - as it plays private func tick(_ secs: Double) { guard secs.isFinite else { return } elapsed = secs if let d = duration { length = d } playing = isPlaying onTime?(secs, duration) updateElapsed(secs) if abs(secs - lastSaved) > 10 { savePosition() } // Listened to, not merely started -- the same 90% the page uses. Doing it on play made an // item vanish from Unread the instant you pressed it. if let d = duration, d > 0, secs / d >= 0.9 { markRead() } } @objc private func didEnd() { savePosition() markRead() playing = false onState?(false) onEnded?() updateNowPlaying() } private func markRead() { guard !markedRead, let item else { return } markedRead = true Task { try? await api.setRead(true, feed: item.feedId, guid: item.guid) } } // MARK: - the car and the lock screen 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 same 15 and 30 the page uses, 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 ? (player?.rate ?? 1) : 0, MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, ] if let d = duration ?? item.duration.map(Double.init) { info[MPMediaItemPropertyPlaybackDuration] = d } let centre = MPNowPlayingInfoCenter.default() centre.nowPlayingInfo = info if let art = item.artwork { Task { guard let image = await api.image(art) else { return } await MainActor.run { // Read back rather than reusing `info`: by the time the picture arrives the // elapsed time in it is stale, and writing it would jerk the car's progress bar. var latest = centre.nowPlayingInfo ?? [:] latest[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } centre.nowPlayingInfo = latest } } } } /// Just the clock, every tick. Rewriting the whole dictionary that often makes the car's /// progress bar stutter. private func updateElapsed(_ secs: Double) { guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return } info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = secs info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? (player?.rate ?? 1) : 0 MPNowPlayingInfoCenter.default().nowPlayingInfo = info } }