Podcast: local MP3 cache, AirPods, lock screen controls, background audio
All checks were successful
CI / build-and-deploy (push) Successful in 23s

- Cache generated MP3 to App Support/podcasts/<hash>.mp3 — re-opening
  the same article plays instantly with zero backend calls
- AVAudioSession .playback/.spokenAudio + .allowBluetoothA2DP — routes
  audio to AirPods and Bluetooth headsets
- MPRemoteCommandCenter play/pause/seek — AirPods controls + lock screen
- MPNowPlayingInfoCenter — title and scrubber on lock screen
- UIBackgroundModes: audio in Info.plist — playback continues when backgrounded
- Auto-play when player is ready

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-22 15:39:14 -05:00
parent d2ff1d2f2f
commit 476ed9b2a1
3 changed files with 84 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
import SwiftUI
import AVFoundation
import MediaPlayer
// MARK: - ViewModel
@@ -21,8 +22,16 @@ final class PodcastPlayerViewModel {
private var pollingTask: Task<Void, Never>?
private var timeObserver: Any?
private var currentTitle: String = "Marks Podcast"
func start(articleUrl: String, claude: ClaudeService) {
// Play from cache if already generated
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
if FileManager.default.fileExists(atPath: cached.path) {
setupPlayer(url: cached, title: "Podcast")
return
}
pollingTask = Task {
do {
let jobId = try await claude.generatePodcast(url: articleUrl)
@@ -34,7 +43,7 @@ final class PodcastPlayerViewModel {
)
if job.isDone {
phase = .downloading
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId)
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return
}
@@ -56,11 +65,13 @@ final class PodcastPlayerViewModel {
guard let player else { return }
if isPlaying { player.pause() } else { player.play() }
isPlaying.toggle()
updateNowPlaying()
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
currentTime = time
updateNowPlaying()
}
func teardown() {
@@ -68,22 +79,78 @@ final class PodcastPlayerViewModel {
player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) }
player = nil
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
let c = MPRemoteCommandCenter.shared()
c.playCommand.removeTarget(nil)
c.pauseCommand.removeTarget(nil)
c.changePlaybackPositionCommand.removeTarget(nil)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private func setupPlayer(url: URL, title: String) {
currentTitle = title
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
options: [.allowBluetoothA2DP])
try? AVAudioSession.sharedInstance().setActive(true)
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
player = p
Task {
if let d = try? await item.asset.load(.duration), d.isNumeric {
duration = max(d.seconds, 1)
updateNowPlaying()
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
self?.currentTime = t.seconds
self?.updateNowPlaying()
}
setupRemoteCommands()
phase = .ready(title: title)
updateNowPlaying()
p.play()
isPlaying = true
}
private func setupRemoteCommands() {
let c = MPRemoteCommandCenter.shared()
c.playCommand.isEnabled = true
c.playCommand.addTarget { [weak self] _ in
self?.player?.play()
self?.isPlaying = true
self?.updateNowPlaying()
return .success
}
c.pauseCommand.isEnabled = true
c.pauseCommand.addTarget { [weak self] _ in
self?.player?.pause()
self?.isPlaying = false
self?.updateNowPlaying()
return .success
}
c.changePlaybackPositionCommand.isEnabled = true
c.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.player?.seek(to: CMTime(seconds: e.positionTime, preferredTimescale: 600))
self?.currentTime = e.positionTime
return .success
}
}
private func updateNowPlaying() {
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentTitle
info[MPMediaItemPropertyArtist] = "Marks"
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private static func statusLabel(_ status: String) -> String {