Podcast: local MP3 cache, AirPods, lock screen controls, background audio
All checks were successful
CI / build-and-deploy (push) Successful in 23s
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:
@@ -24,5 +24,9 @@
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -67,14 +67,23 @@ struct ClaudeService: Sendable {
|
||||
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 dest = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("podcast-\(jobId).mp3")
|
||||
try data.write(to: dest, options: .atomic)
|
||||
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
|
||||
|
||||
private func post(_ path: String, body: [String: Any]) async throws -> Data {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user