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 {