import SwiftUI import AVFoundation // 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? func start(articleUrl: String, claude: ClaudeService) { 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) 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() } func seek(to time: Double) { player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) currentTime = time } func teardown() { pollingTask?.cancel() player?.pause() if let obs = timeObserver { player?.removeTimeObserver(obs) } player = nil } private func setupPlayer(url: URL, title: String) { 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) } } let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in self?.currentTime = t.seconds } phase = .ready(title: title) } 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) } }