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>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user