import AVFoundation import Foundation import MediaPlayer /// 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. final class Playback: NSObject { /// 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 library: Library 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, library: Library) { self.cookies = cookies self.library = library 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 // 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" self.library.check { good in DispatchQueue.main.async { self.onError?(good ? 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() onState?(true) updateNowPlaying() } func pause() { player?.pause() 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 MPNowPlayingInfoCenter.default().nowPlayingInfo = nil try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) if notify { onState?(false) } } /// The page asking us to save, because its own beacon is ours now. It sends the path rather /// than a time: the time it has may be minutes old if it has been frozen in the background. func savePosition(path: String? = nil) { 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) let dur = duration.map { Int($0.rounded()) } if let path { library.savePosition(path: path, secs: secs, duration: dur) } else { library.savePosition(feedId: item.feedId, guid: item.guid, secs: secs, duration: dur) } } // 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() } // 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() onState?(false) onEnded?() updateNowPlaying() } private func markRead() { guard !markedRead, let item else { return } markedRead = true library.markRead(feedId: 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 { library.artwork(art) { image in guard let image else { return } DispatchQueue.main.async { // 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 } }