diff --git a/Marks/Services/AnalyticsService.swift b/Marks/Services/AnalyticsService.swift index d138ff0..06fe485 100644 --- a/Marks/Services/AnalyticsService.swift +++ b/Marks/Services/AnalyticsService.swift @@ -2,6 +2,10 @@ import Foundation enum Analytics { static func track(_ event: String, _ properties: [String: Any] = [:]) { + // Serialize before crossing the task boundary — Data is Sendable, [String: Any] is not + var body: [String: Any] = ["event": event] + if !properties.isEmpty { body["properties"] = properties } + guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return } Task.detached { do { let token = try await MarksAuth.validToken() @@ -10,9 +14,7 @@ enum Analytics { request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - var body: [String: Any] = ["event": event] - if !properties.isEmpty { body["properties"] = properties } - request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.httpBody = payload let (_, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 if status < 200 || status > 299 { diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 651b8ce..6644524 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -11,6 +11,7 @@ struct BookmarksView: View { @State private var editingBookmark: Bookmark? @State private var browsingBookmark: Bookmark? @State private var showFullPlayer = false + @State private var showPodcastLibrary = false @State private var readingProgress: [String: Double] = ReadingProgress.all() var body: some View { @@ -118,6 +119,11 @@ struct BookmarksView: View { } label: { Label("Enrich All with AI", systemImage: "wand.and.stars") } + Button { + showPodcastLibrary = true + } label: { + Label("Podcasts", systemImage: "headphones") + } } label: { Image(systemName: "sparkles") } @@ -150,7 +156,7 @@ struct BookmarksView: View { } .overlay(alignment: .bottom) { VStack(spacing: 8) { - if viewModel.podcastPlayer.player != nil { + if !viewModel.podcastPlayer.currentArticleUrl.isEmpty { MiniPlayerView(vm: viewModel.podcastPlayer) { showFullPlayer = true } @@ -160,7 +166,7 @@ struct BookmarksView: View { enrichmentBanner } } - .animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil) + .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty) .padding(.bottom, 8) } } @@ -193,6 +199,9 @@ struct BookmarksView: View { stopOnDismiss: false ) } + .sheet(isPresented: $showPodcastLibrary) { + PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) + } .refreshable { await viewModel.load() } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 1749882..c08df3a 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -43,13 +43,21 @@ final class PodcastPlayerViewModel { var currentArticleUrl: String = "" var currentArticleTitle: String = "" + 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 the same article if already playing - if currentArticleUrl == articleUrl && player != nil { return } + // 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() } @@ -105,6 +113,8 @@ final class PodcastPlayerViewModel { 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 @@ -158,6 +168,18 @@ final class PodcastPlayerViewModel { } } + // 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 } @@ -243,7 +265,7 @@ struct PodcastPlayerView: View { ToolbarItem(placement: .topBarLeading) { Button("Done") { dismiss() } } - if vm.player != nil { + if !vm.currentArticleUrl.isEmpty { ToolbarItem(placement: .topBarTrailing) { Button { vm.stop(); dismiss() } label: { Image(systemName: "stop.circle") @@ -365,7 +387,7 @@ struct MiniPlayerView: View { Image(systemName: "waveform") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.blue) - .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying) + .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating) .frame(width: 22) VStack(alignment: .leading, spacing: 2) { @@ -380,15 +402,20 @@ struct MiniPlayerView: View { Spacer() - Button { - vm.togglePlayPause() - } label: { - Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 18)) - .foregroundStyle(.primary) + 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) } - .buttonStyle(.plain) Button { vm.stop() @@ -435,3 +462,62 @@ struct MiniPlayerView: View { 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() } + } +}