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

@@ -24,5 +24,9 @@
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
</array> </array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -67,14 +67,23 @@ struct ClaudeService: Sendable {
return try JSONDecoder().decode(PodcastStatus.self, from: data) return try JSONDecoder().decode(PodcastStatus.self, from: data)
} }
func downloadPodcastAudio(jobId: String) async throws -> URL { func downloadPodcastAudio(jobId: String, articleUrl: String) async throws -> URL {
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try await get("/v1/podcast/audio/\(jobId)") let data = try await get("/v1/podcast/audio/\(jobId)")
let dest = FileManager.default.temporaryDirectory
.appendingPathComponent("podcast-\(jobId).mp3")
try data.write(to: dest, options: .atomic) try data.write(to: dest, options: .atomic)
return dest return dest
} }
static func cachedPodcastURL(for articleUrl: String) -> URL {
// djb2 hash stable, no CryptoKit needed
let hash = articleUrl.utf8.reduce(UInt64(5381)) { ($0 &* 31) &+ UInt64($1) }
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("podcasts")
return dir.appendingPathComponent("\(hash).mp3")
}
// MARK: - HTTP primitives // MARK: - HTTP primitives
private func post(_ path: String, body: [String: Any]) async throws -> Data { private func post(_ path: String, body: [String: Any]) async throws -> Data {

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import AVFoundation import AVFoundation
import MediaPlayer
// MARK: - ViewModel // MARK: - ViewModel
@@ -21,8 +22,16 @@ final class PodcastPlayerViewModel {
private var pollingTask: Task<Void, Never>? private var pollingTask: Task<Void, Never>?
private var timeObserver: Any? private var timeObserver: Any?
private var currentTitle: String = "Marks Podcast"
func start(articleUrl: String, claude: ClaudeService) { 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 { pollingTask = Task {
do { do {
let jobId = try await claude.generatePodcast(url: articleUrl) let jobId = try await claude.generatePodcast(url: articleUrl)
@@ -34,7 +43,7 @@ final class PodcastPlayerViewModel {
) )
if job.isDone { if job.isDone {
phase = .downloading 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") setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return return
} }
@@ -56,11 +65,13 @@ final class PodcastPlayerViewModel {
guard let player else { return } guard let player else { return }
if isPlaying { player.pause() } else { player.play() } if isPlaying { player.pause() } else { player.play() }
isPlaying.toggle() isPlaying.toggle()
updateNowPlaying()
} }
func seek(to time: Double) { func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
currentTime = time currentTime = time
updateNowPlaying()
} }
func teardown() { func teardown() {
@@ -68,22 +79,78 @@ final class PodcastPlayerViewModel {
player?.pause() player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) } if let obs = timeObserver { player?.removeTimeObserver(obs) }
player = nil 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) { 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 item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item) let p = AVPlayer(playerItem: item)
player = p player = p
Task { Task {
if let d = try? await item.asset.load(.duration), d.isNumeric { if let d = try? await item.asset.load(.duration), d.isNumeric {
duration = max(d.seconds, 1) duration = max(d.seconds, 1)
updateNowPlaying()
} }
} }
let interval = CMTime(seconds: 0.5, preferredTimescale: 600) let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
self?.currentTime = t.seconds self?.currentTime = t.seconds
self?.updateNowPlaying()
} }
setupRemoteCommands()
phase = .ready(title: title) 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 { private static func statusLabel(_ status: String) -> String {