import SwiftUI import AVFoundation import MediaPlayer // MARK: - Playback position persistence enum PodcastProgress { private static let key = "podcastProgress" static func get(url: String) -> Double { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0 } static func set(url: String, time: Double, duration: Double) { var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] let fraction = time / max(duration, 1) if fraction > 0.97 || time < 10 { store.removeValue(forKey: url) } else { store[url] = time } UserDefaults.standard.set(store, forKey: key) } } // 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 var currentArticleUrl: String = "" var currentArticleTitle: String = "" var playbackSpeed: Float = 1.0 static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0] var isGenerating: Bool { switch phase { case .generating, .downloading: return true default: return false } } private var pollingTask: Task? private var timeObserver: Any? private var endObserver: Any? private var currentPodcastTitle: String = "Marks Podcast" func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) { // Idempotent — don't restart if already generating or playing this article if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return } // Teardown previous episode (saves its position) if player != nil { stop() } currentArticleUrl = articleUrl currentArticleTitle = articleTitle let cached = ClaudeService.cachedPodcastURL(for: articleUrl) if FileManager.default.fileExists(atPath: cached.path) { Analytics.track("podcast.started", ["url": articleUrl, "cached": true]) setupPlayer(url: cached, title: "Podcast") return } Analytics.track("podcast.started", ["url": articleUrl, "cached": false]) phase = .generating(progress: 0, label: "Starting…") 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, title: job.title) setupPlayer(url: audioUrl, title: job.title ?? "Podcast") return } if job.isFailed { let errMsg = job.error ?? "Generation failed" Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg]) phase = .failed(errMsg) pollingTask = nil return } try await Task.sleep(for: .seconds(3)) } } catch is CancellationError { } catch { Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription]) phase = .failed(error.localizedDescription) pollingTask = nil } } } func stop() { if !currentArticleUrl.isEmpty && currentTime > 10 { PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration) } pollingTask?.cancel() player?.pause() if let obs = timeObserver { player?.removeTimeObserver(obs) } if let obs = endObserver { NotificationCenter.default.removeObserver(obs) } endObserver = nil player = nil isPlaying = false currentTime = 0 duration = 1 currentArticleUrl = "" currentArticleTitle = "" currentPodcastTitle = "Marks Podcast" phase = .generating(progress: 0, label: "Starting…") MPNowPlayingInfoCenter.default().nowPlayingInfo = nil let c = MPRemoteCommandCenter.shared() c.playCommand.removeTarget(nil) c.pauseCommand.removeTarget(nil) c.changePlaybackPositionCommand.removeTarget(nil) c.skipBackwardCommand.removeTarget(nil) c.skipForwardCommand.removeTarget(nil) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } func togglePlayPause() { guard let player else { return } if isPlaying { player.pause() } else { player.rate = playbackSpeed } isPlaying.toggle() updateNowPlaying() } func seek(to time: Double) { player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) currentTime = time updateNowPlaying() } func skipForward15() { seek(to: min(currentTime + 15, duration)) } func skipBackward15() { seek(to: max(currentTime - 15, 0)) } func setPlaybackSpeed(_ speed: Float) { playbackSpeed = speed if isPlaying { player?.rate = speed } updateNowPlaying() } private func setupPlayer(url: URL, title: String) { currentPodcastTitle = 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) // Restore saved position let saved = PodcastProgress.get(url: currentArticleUrl) if saved > 10 { await p.seek(to: CMTime(seconds: saved, preferredTimescale: 600)) currentTime = saved } updateNowPlaying() } } // Reset to start when playback finishes, like standard media players endObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { [weak self] _ in guard let self else { return } self.isPlaying = false self.currentTime = 0 self.player?.seek(to: .zero) PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration) self.updateNowPlaying() } let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in guard let self else { return } let prev = self.currentTime self.currentTime = t.seconds self.updateNowPlaying() // Track completion when crossing 97% if self.duration > 1 && prev / self.duration < 0.97 && t.seconds / self.duration >= 0.97 { Analytics.track("podcast.completed", ["url": self.currentArticleUrl, "durationSec": Int(self.duration)]) } } setupRemoteCommands() phase = .ready(title: title) updateNowPlaying() p.rate = playbackSpeed 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 } c.skipBackwardCommand.isEnabled = true c.skipBackwardCommand.preferredIntervals = [15] c.skipBackwardCommand.addTarget { [weak self] _ in self?.skipBackward15(); return .success } c.skipForwardCommand.isEnabled = true c.skipForwardCommand.preferredIntervals = [15] c.skipForwardCommand.addTarget { [weak self] _ in self?.skipForward15(); return .success } } private func updateNowPlaying() { var info: [String: Any] = [:] info[MPMediaItemPropertyTitle] = currentPodcastTitle info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle 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: - Full player sheet struct PodcastPlayerView: View { let vm: PodcastPlayerViewModel let articleUrl: String let articleTitle: String let claude: ClaudeService var stopOnDismiss: Bool = false @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { VStack { Spacer() phaseContent Spacer() } .padding(.horizontal, 32) .navigationTitle("Podcast") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Done") { dismiss() } } if !vm.currentArticleUrl.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button { vm.stop(); dismiss() } label: { Image(systemName: "stop.circle") .foregroundStyle(.red) } } } } } .onAppear { vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) } .onDisappear { if stopOnDismiss { vm.stop() } } } @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) } HStack(spacing: 36) { Button { vm.skipBackward15() } label: { Image(systemName: "gobackward.15") .font(.system(size: 32)) .foregroundStyle(.primary) } Button { vm.togglePlayPause() } label: { Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) .foregroundStyle(.primary) } Button { vm.skipForward15() } label: { Image(systemName: "goforward.15") .font(.system(size: 32)) .foregroundStyle(.primary) } } Menu { ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in Button { vm.setPlaybackSpeed(speed) } label: { let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×" if speed == vm.playbackSpeed { Label(label, systemImage: "checkmark") } else { Text(label) } } } } label: { Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .background(Color(.systemGray5)) .clipShape(Capsule()) } } } 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) Button { vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) } label: { Label("Retry", systemImage: "arrow.clockwise") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 24) .padding(.vertical, 10) .background(Color.blue) .clipShape(Capsule()) } .padding(.top, 4) } } private func formatTime(_ s: Double) -> String { let total = Int(max(s, 0)) return String(format: "%d:%02d", total / 60, total % 60) } } // MARK: - Mini player struct MiniPlayerView: View { let vm: PodcastPlayerViewModel let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 14) { Image(systemName: "waveform") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.blue) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating) .frame(width: 22) VStack(alignment: .leading, spacing: 2) { Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) Text(progressLabel) .font(.system(size: 12)) .foregroundStyle(.secondary) } Spacer() if vm.isGenerating { ProgressView() .frame(width: 36, height: 36) } else { Button { vm.togglePlayPause() } label: { Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 18)) .foregroundStyle(.primary) .frame(width: 36, height: 36) } .buttonStyle(.plain) } Button { vm.stop() } label: { Image(systemName: "xmark") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.secondary) .frame(width: 28, height: 28) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(alignment: .bottom) { // Progress bar along bottom edge GeometryReader { geo in Rectangle() .fill(Color.blue.opacity(0.6)) .frame(width: geo.size.width * progressFraction, height: 3) } .frame(height: 3) .clipShape(RoundedRectangle(cornerRadius: 16)) } .shadow(color: .black.opacity(0.12), radius: 12, x: 0, y: 4) } .buttonStyle(.plain) .padding(.horizontal, 12) } private var progressFraction: Double { guard vm.duration > 1 else { return 0 } return min(vm.currentTime / vm.duration, 1) } private var progressLabel: String { guard vm.duration > 1 else { if case .generating(_, let label) = vm.phase { return label } return "Generating…" } let remaining = vm.duration - vm.currentTime let mins = Int(remaining) / 60 return mins > 0 ? "\(mins) min left" : "Almost done" } } // MARK: - Podcast library struct PodcastLibraryView: View { let vm: PodcastPlayerViewModel let claude: ClaudeService @State private var entries: [PodcastEntry] = [] @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { Group { if entries.isEmpty { ContentUnavailableView( "No Podcasts", systemImage: "headphones", description: Text("Podcasts you generate will appear here.") ) } else { List(entries) { entry in HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) .font(.system(size: 15, weight: .medium)) .lineLimit(2) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) .font(.system(size: 12)) .foregroundStyle(.secondary) } Spacer() Button { vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? "", claude: claude) dismiss() } label: { Image(systemName: "play.circle.fill") .font(.system(size: 36)) .foregroundStyle(.blue) } .buttonStyle(.plain) } .padding(.vertical, 6) } .listStyle(.plain) } } .navigationTitle("Podcasts") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } } .onAppear { entries = PodcastIndex.all() } } }