diff --git a/Marks/Info.plist b/Marks/Info.plist
index f92ba43..662dabe 100644
--- a/Marks/Info.plist
+++ b/Marks/Info.plist
@@ -24,5 +24,9 @@
UIInterfaceOrientationPortrait
+ UIBackgroundModes
+
+ audio
+
diff --git a/Marks/Services/ClaudeService.swift b/Marks/Services/ClaudeService.swift
index 5c5ca45..26a19a2 100644
--- a/Marks/Services/ClaudeService.swift
+++ b/Marks/Services/ClaudeService.swift
@@ -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 {
diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift
index cb21074..ca06e6c 100644
--- a/Marks/Views/PodcastPlayerView.swift
+++ b/Marks/Views/PodcastPlayerView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AVFoundation
+import MediaPlayer
// MARK: - ViewModel
@@ -21,8 +22,16 @@ final class PodcastPlayerViewModel {
private var pollingTask: Task?
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 {