import SwiftUI import AVFoundation import MediaPlayer // MARK: - ViewModel @Observable @MainActor final class PodcastPlayerViewModel { enum Phase { case generating(progress: Double, label: String) case downloading case ready(title: String) case failed(String) } var phase: Phase = .generating(progress: 0, label: "Starting…") var player: AVPlayer? var isPlaying = false var currentTime: Double = 0 var duration: Double = 1 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) while !Task.isCancelled { let job = try await claude.podcastStatus(jobId: jobId) phase = .generating( progress: Double(job.progress) / 100, label: Self.statusLabel(job.status) ) if job.isDone { phase = .downloading let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl) setupPlayer(url: audioUrl, title: job.title ?? "Podcast") return } if job.isFailed { phase = .failed(job.error ?? "Generation failed") return } try await Task.sleep(for: .seconds(3)) } } catch is CancellationError { // sheet dismissed — clean exit } catch { phase = .failed(error.localizedDescription) } } } func togglePlayPause() { 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() { pollingTask?.cancel() 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 { switch status { case "queued": return "Queued…" case "fetching": return "Fetching article…" case "scripting": return "Writing dialogue…" case "recording": return "Recording voices…" case "mixing": return "Mixing audio…" default: return "Generating…" } } } // MARK: - View struct PodcastPlayerView: View { let articleUrl: String let claude: ClaudeService @Environment(\.dismiss) private var dismiss @State private var vm = PodcastPlayerViewModel() var body: some View { NavigationStack { VStack { Spacer() phaseContent Spacer() } .padding(.horizontal, 32) .navigationTitle("Podcast") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Done") { dismiss() } } } } .onAppear { vm.start(articleUrl: articleUrl, claude: claude) } .onDisappear { vm.teardown() } } @ViewBuilder private var phaseContent: some View { switch vm.phase { case .generating(let progress, let label): generatingView(progress: progress, label: label) case .downloading: generatingView(progress: 1.0, label: "Preparing audio…") case .ready(let title): playerView(title: title) case .failed(let message): failedView(message: message) } } private func generatingView(progress: Double, label: String) -> some View { VStack(spacing: 28) { Image(systemName: "waveform") .font(.system(size: 52)) .foregroundStyle(.secondary) .symbolEffect(.variableColor.iterative, isActive: true) VStack(spacing: 10) { ProgressView(value: progress) .progressViewStyle(.linear) .tint(.blue) Text(label) .font(.system(size: 14)) .foregroundStyle(.secondary) } } } private func playerView(title: String) -> some View { VStack(spacing: 28) { RoundedRectangle(cornerRadius: 20) .fill(Color(.systemGray6)) .frame(width: 220, height: 220) .overlay { Image(systemName: "waveform.circle.fill") .font(.system(size: 80)) .foregroundStyle(.blue) } Text(title) .font(.system(size: 18, weight: .semibold)) .multilineTextAlignment(.center) .lineLimit(3) VStack(spacing: 6) { Slider( value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }), in: 0...vm.duration ) .tint(.primary) HStack { Text(formatTime(vm.currentTime)) Spacer() Text(formatTime(vm.duration)) } .font(.system(size: 12, design: .monospaced)) .foregroundStyle(.tertiary) } Button { vm.togglePlayPause() } label: { Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) .foregroundStyle(.primary) } } } private func failedView(message: String) -> some View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 44)) .foregroundStyle(.red) Text("Generation Failed") .font(.headline) Text(message) .font(.system(size: 14)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } private func formatTime(_ s: Double) -> String { let total = Int(max(s, 0)) return String(format: "%d:%02d", total / 60, total % 60) } }