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 = PodcastPlayerViewModel.savedSpeed static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0] /// Sleep-timer selection. `.off` when disabled. enum SleepTimer: Hashable { case off, minutes(Int), endOfEpisode } var sleepTimer: SleepTimer = .off private static let speedKey = "podcastPlaybackSpeed" private static let sessionKey = "podcastSession" static var savedSpeed: Float { let v = UserDefaults.standard.double(forKey: speedKey) return v > 0 ? Float(v) : 1.0 } var isGenerating: Bool { switch phase { case .generating, .downloading: return true default: return false } } /// Remaining episodes to auto-play after the current one finishes. private(set) var queue: [PodcastEntry] = [] /// Retained so queued episodes can be started from the end-of-item observer. private var lastClaude: ClaudeService? private var pollingTask: Task? private var sleepTask: Task? private var timeObserver: Any? private var endObserver: Any? private var interruptionObserver: Any? private var currentPodcastTitle: String = "Marks Podcast" func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) { // 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() } lastClaude = claude 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, parentBookmarkUrl: parentBookmarkUrl) 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 } } } /// Play `entries` in order, auto-advancing through the queue as each finishes. func playQueue(_ entries: [PodcastEntry], claude: ClaudeService) { guard let first = entries.first else { return } // `start` calls `stop`, which clears the queue — set it afterwards. start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude) queue = Array(entries.dropFirst()) persistSession() } func stop() { queue = [] clearSession() sleepTask?.cancel(); sleepTask = nil sleepTimer = .off 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 if let obs = interruptionObserver { NotificationCenter.default.removeObserver(obs) } interruptionObserver = 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 { try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session 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 UserDefaults.standard.set(Double(speed), forKey: Self.speedKey) if isPlaying { player?.rate = speed } updateNowPlaying() } // MARK: - Sleep timer func setSleepTimer(_ timer: SleepTimer) { sleepTimer = timer sleepTask?.cancel(); sleepTask = nil guard case .minutes(let m) = timer else { return } // .endOfEpisode handled at finish sleepTask = Task { [weak self] in try? await Task.sleep(for: .seconds(Double(m) * 60)) guard !Task.isCancelled else { return } self?.fireSleepTimer() } } private func fireSleepTimer() { player?.pause() isPlaying = false sleepTimer = .off sleepTask = nil updateNowPlaying() } // MARK: - Session persistence (survives app relaunch) private struct SavedSession: Codable { let currentUrl: String let currentTitle: String let podcastTitle: String let queue: [PodcastEntry] } /// Persist the current episode + queue so relaunch can restore a paused session. func persistSession() { guard !currentArticleUrl.isEmpty else { clearSession(); return } let session = SavedSession(currentUrl: currentArticleUrl, currentTitle: currentArticleTitle, podcastTitle: currentPodcastTitle, queue: queue) if let data = try? JSONEncoder().encode(session) { UserDefaults.standard.set(data, forKey: Self.sessionKey) } } private func clearSession() { UserDefaults.standard.removeObject(forKey: Self.sessionKey) } /// Save the current playback position + session (call when backgrounding). func saveProgress() { if !currentArticleUrl.isEmpty && currentTime > 10 { PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration) } persistSession() } /// Restore a previously-playing episode into a **paused** player on launch, /// with its queue intact. No-op if nothing is saved or the audio is gone. func restoreSession(claude: ClaudeService) { guard currentArticleUrl.isEmpty, player == nil else { return } guard let data = UserDefaults.standard.data(forKey: Self.sessionKey), let session = try? JSONDecoder().decode(SavedSession.self, from: data) else { return } let cached = ClaudeService.cachedPodcastURL(for: session.currentUrl) guard FileManager.default.fileExists(atPath: cached.path) else { clearSession(); return } lastClaude = claude currentArticleUrl = session.currentUrl currentArticleTitle = session.currentTitle queue = session.queue setupPlayer(url: cached, title: session.podcastTitle, autoPlay: false) } private func setupPlayer(url: URL, title: String, autoPlay: Bool = true) { currentPodcastTitle = title // A freshly generated episode may have just landed in the index. PodcastLibrary.shared.reload() try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowBluetoothA2DP]) // Only claim the audio session when actually playing — a restored, paused // session on launch must not interrupt the user's other audio. if autoPlay { 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() } } // On finish: mark the episode played, then auto-advance to the next // queued episode, or reset to the start like a standard media player. endObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { [weak self] _ in guard let self else { return } let finished = self.currentArticleUrl PodcastIndex.setPlayed(articleUrl: finished, true) PodcastProgress.set(url: finished, time: 0, duration: self.duration) PodcastLibrary.shared.reload() // Sleep timer set to "end of episode" stops here — no auto-advance. let stopForSleep = self.sleepTimer == .endOfEpisode if stopForSleep { self.sleepTimer = .off } if !stopForSleep, !self.queue.isEmpty { let next = self.queue.removeFirst() let remaining = self.queue let claude = self.lastClaude ?? ClaudeService() self.start(articleUrl: next.articleUrl, articleTitle: next.title ?? "", claude: claude) self.queue = remaining // `start` cleared it via `stop` self.persistSession() } else { self.isPlaying = false self.currentTime = 0 self.player?.seek(to: .zero) self.updateNowPlaying() self.persistSession() } } // Pause on phone calls / Siri; resume when interruption ends interruptionObserver = NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { [weak self] notification in guard let self, let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } if type == .began { self.player?.pause() self.isPlaying = false self.updateNowPlaying() } else if type == .ended { let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { self.player?.rate = self.playbackSpeed self.isPlaying = true 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() if autoPlay { p.rate = playbackSpeed isPlaying = true } else { isPlaying = false } persistSession() } private func setupRemoteCommands() { let c = MPRemoteCommandCenter.shared() c.playCommand.isEnabled = true c.playCommand.addTarget { [weak self] _ in try? AVAudioSession.sharedInstance().setActive(true) 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 ? Double(playbackSpeed) : 0.0 info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration } if let artwork = Self.nowPlayingArtwork { info[MPMediaItemPropertyArtwork] = artwork } MPNowPlayingInfoCenter.default().nowPlayingInfo = info } private static let nowPlayingArtwork: MPMediaItemArtwork? = { let size = CGSize(width: 300, height: 300) let renderer = UIGraphicsImageRenderer(size: size) let image = renderer.image { ctx in UIColor.systemBlue.withAlphaComponent(0.15).setFill() ctx.fill(CGRect(origin: .zero, size: size)) let config = UIImage.SymbolConfiguration(pointSize: 140, weight: .regular) if let symbol = UIImage(systemName: "waveform.circle.fill", withConfiguration: config) { let tinted = symbol.withTintColor(.systemBlue, renderingMode: .alwaysOriginal) let origin = CGPoint(x: (size.width - tinted.size.width) / 2, y: (size.height - tinted.size.height) / 2) tinted.draw(at: origin) } } return MPMediaItemArtwork(boundsSize: size) { _ in image } }() 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 @State private var isPlayed = false /// The episode currently loaded in the player (follows queue advances), /// falling back to the URL this sheet was opened with. private var activeUrl: String { vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl } private var sleepActive: Bool { vm.sleepTimer != .off } private var sleepLabel: String { switch vm.sleepTimer { case .off: return "Sleep" case .minutes(let m): return "\(m) min" case .endOfEpisode: return "End" } } @ViewBuilder private func sleepButton(_ title: String, _ option: PodcastPlayerViewModel.SleepTimer) -> some View { Button { vm.setSleepTimer(option) } label: { if vm.sleepTimer == option { Label(title, systemImage: "checkmark") } else { Text(title) } } } 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) isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl) } .onChange(of: vm.currentArticleUrl) { _, _ in isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl) } .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) } } HStack(spacing: 12) { 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) .glassEffect(in: Capsule()) } Menu { sleepButton("Off", .off) sleepButton("15 Minutes", .minutes(15)) sleepButton("30 Minutes", .minutes(30)) sleepButton("45 Minutes", .minutes(45)) sleepButton("End of Episode", .endOfEpisode) } label: { Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(sleepActive ? Color.blue : .secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) } } HStack(spacing: 28) { Button { isPlayed.toggle() PodcastLibrary.shared.setPlayed(activeUrl, isPlayed) } label: { Label(isPlayed ? "Mark as Unplayed" : "Mark as Played", systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle") .font(.system(size: 14, weight: .medium)) .foregroundStyle(isPlayed ? Color.green : .secondary) } .buttonStyle(.plain) .contentTransition(.symbolEffect(.replace)) if let url = URL(string: articleUrl) { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { Label("Share Episode", systemImage: "square.and.arrow.up") .font(.system(size: 14, weight: .medium)) .foregroundStyle(.secondary) } } } } } 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)) } .buttonStyle(.glassProminent) .tint(.blue) .buttonBorderShape(.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 /// The Podcasts tab: lists every podcast generated in the app (from /// `PodcastIndex`) and plays them on demand through the shared player, with a /// now-playing bar that opens the full player. struct PodcastLibraryView: View { let vm: PodcastPlayerViewModel let claude: ClaudeService @State private var library = PodcastLibrary.shared @State private var showFullPlayer = false @State private var detailEntry: PodcastEntry? var body: some View { NavigationStack { Group { if library.entries.isEmpty { ContentUnavailableView( "No Podcasts", systemImage: "headphones", description: Text("Podcasts you generate from bookmarks will appear here.") ) } else { List { if !unplayed.isEmpty { Section("Up Next") { ForEach(unplayed) { row(for: $0) } } } if !played.isEmpty { Section("Played") { ForEach(played) { row(for: $0) } } } } .listStyle(.insetGrouped) } } .navigationTitle("Podcasts") .toolbar { if !unplayed.isEmpty { ToolbarItem(placement: .topBarLeading) { Button { vm.playQueue(unplayed, claude: claude) } label: { Label("Play All", systemImage: "play.fill") } } } } .overlay(alignment: .bottom) { if !vm.currentArticleUrl.isEmpty { MiniPlayerView(vm: vm) { showFullPlayer = true } .padding(.horizontal) .padding(.bottom, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) } } .animation(.spring(duration: 0.3), value: vm.currentArticleUrl.isEmpty) .sheet(isPresented: $showFullPlayer) { PodcastPlayerView( vm: vm, articleUrl: vm.currentArticleUrl, articleTitle: vm.currentArticleTitle, claude: claude, stopOnDismiss: false ) } .sheet(item: $detailEntry) { entry in EpisodeDetailView(entry: entry, vm: vm, claude: claude) } } .onAppear { library.reload() } // When the current episode changes (finished, advanced, or stopped), // re-read the index so played items move into the Played section. .onChange(of: vm.currentArticleUrl) { _, _ in library.reload() } } private var unplayed: [PodcastEntry] { library.unplayed } private var played: [PodcastEntry] { library.played } @ViewBuilder private func row(for entry: PodcastEntry) -> some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) .font(.system(size: 15, weight: .medium)) .lineLimit(2) .foregroundStyle(entry.isPlayed ? .secondary : .primary) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) .font(.system(size: 12)) .foregroundStyle(.secondary) } Spacer() if entry.isPlayed { Image(systemName: "checkmark.circle.fill") .font(.system(size: 15)) .foregroundStyle(.green) .accessibilityLabel("Played") } Button { play(entry) } label: { Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 36)) .foregroundStyle(.blue) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) } .padding(.vertical, 6) .contentShape(Rectangle()) .onTapGesture { detailEntry = entry } .swipeActions(edge: .leading) { Button { setPlayed(entry, !entry.isPlayed) } label: { if entry.isPlayed { Label("Unplayed", systemImage: "circle") } else { Label("Played", systemImage: "checkmark.circle") } } .tint(entry.isPlayed ? .gray : .green) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { deleteEntry(entry) } label: { Label("Delete", systemImage: "trash") } } .contextMenu { Button { play(entry) } label: { Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play", systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play") } Button { detailEntry = entry } label: { Label("Episode Details", systemImage: "info.circle") } Button { setPlayed(entry, !entry.isPlayed) } label: { if entry.isPlayed { Label("Mark as Unplayed", systemImage: "circle") } else { Label("Mark as Played", systemImage: "checkmark.circle") } } Divider() Button(role: .destructive) { deleteEntry(entry) } label: { Label("Delete", systemImage: "trash") } } } private func setPlayed(_ entry: PodcastEntry, _ played: Bool) { library.setPlayed(entry.articleUrl, played) } private func isCurrent(_ entry: PodcastEntry) -> Bool { vm.currentArticleUrl == entry.articleUrl } private func play(_ entry: PodcastEntry) { if isCurrent(entry) { vm.togglePlayPause() } else { vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? "", claude: claude) } } private func deleteEntry(_ entry: PodcastEntry) { library.remove(entry.articleUrl) if vm.currentArticleUrl == entry.articleUrl { vm.stop() } } } // MARK: - Episode picker (multiple episodes per bookmark) struct EpisodePickerView: View { let bookmark: Bookmark let vm: PodcastPlayerViewModel let claude: ClaudeService @State private var entries: [PodcastEntry] = [] @State private var detailEntry: PodcastEntry? @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { ForEach(entries) { entry in HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) .font(.system(size: 15, weight: .medium)) .lineLimit(2) if entry.articleUrl != bookmark.url { Text(entry.articleUrl) .font(.system(size: 11)) .foregroundStyle(.tertiary) .lineLimit(1) } Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) .font(.system(size: 12)) .foregroundStyle(.secondary) } Spacer() Button { vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? bookmark.displayTitle, claude: claude) dismiss() } label: { Image(systemName: "play.circle.fill") .font(.system(size: 36)) .foregroundStyle(.blue) } .buttonStyle(.plain) } .padding(.vertical, 6) .contentShape(Rectangle()) .onTapGesture { detailEntry = entry } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { deleteEntry(entry) } label: { Label("Delete", systemImage: "trash") } } } } .listStyle(.plain) .navigationTitle("Episodes") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } .sheet(item: $detailEntry) { entry in EpisodeDetailView(entry: entry, vm: vm, claude: claude) } } .onAppear { entries = PodcastIndex.find(for: bookmark.url) } } private func deleteEntry(_ entry: PodcastEntry) { PodcastIndex.remove(articleUrl: entry.articleUrl) entries.removeAll { $0.articleUrl == entry.articleUrl } if vm.currentArticleUrl == entry.articleUrl { vm.stop() } } } // MARK: - Episode detail /// Detail sheet for a single episode: reconnects the podcast to its source /// bookmark and gathers per-episode actions (play, mark played, open source, /// share, delete) in one place. struct EpisodeDetailView: View { let entry: PodcastEntry let vm: PodcastPlayerViewModel let claude: ClaudeService @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var library = PodcastLibrary.shared private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl } private var isPlayed: Bool { library.entries.first { $0.articleUrl == entry.articleUrl }?.isPlayed ?? entry.isPlayed } private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) } private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl } var body: some View { NavigationStack { List { Section { VStack(alignment: .leading, spacing: 8) { Text(entry.title ?? entry.articleUrl) .font(.system(size: 20, weight: .semibold)) Label(domain, systemImage: "link") .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) HStack(spacing: 12) { Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar") if isPlayed { Label("Played", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } Section { Button { if isCurrent { vm.togglePlayPause() } else { vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? "", claude: claude) } } label: { Label(isCurrent && vm.isPlaying ? "Pause" : "Play", systemImage: isCurrent && vm.isPlaying ? "pause.fill" : "play.fill") } Button { library.setPlayed(entry.articleUrl, !isPlayed) } label: { Label(isPlayed ? "Mark as Unplayed" : "Mark as Played", systemImage: isPlayed ? "circle" : "checkmark.circle") } } if let sourceURL { Section { Button { openURL(sourceURL) } label: { Label("Open Source", systemImage: "safari") } ShareLink(item: sourceURL) { Label("Share Episode", systemImage: "square.and.arrow.up") } } } Section { Button(role: .destructive) { library.remove(entry.articleUrl) if vm.currentArticleUrl == entry.articleUrl { vm.stop() } dismiss() } label: { Label("Delete Episode", systemImage: "trash") } } } .navigationTitle("Episode") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } } } }