From abe35f47d653b9e565a48ab3cc7c1f8081cc088a Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Fri, 22 May 2026 16:19:21 -0500 Subject: [PATCH] Add mini-player, per-row podcast button, and resume-from-position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MiniPlayerView: persistent bar above tab bar, shows title/progress, play/pause and close buttons, slides in when audio starts - PodcastPlayerViewModel lifted to BookmarksViewModel so it persists across sheet dismissals — audio keeps playing in background - Headphone button on every bookmark row (filled = cached, outline = not) - start() is idempotent: re-tapping same article resumes without restart - stop() saves playback position before teardown (PodcastProgress) - setupPlayer restores saved position on next open - BrowserView keeps its own local VM (stopOnDismiss: true) Co-Authored-By: Claude Sonnet 4.6 --- Marks/Views/BookmarkRow.swift | 17 +++ Marks/Views/BookmarksView.swift | 51 +++++-- Marks/Views/BookmarksViewModel.swift | 1 + Marks/Views/BrowserView.swift | 9 +- Marks/Views/PodcastPlayerView.swift | 203 ++++++++++++++++++++++----- 5 files changed, 238 insertions(+), 43 deletions(-) diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift index ef450d0..c549afe 100644 --- a/Marks/Views/BookmarkRow.swift +++ b/Marks/Views/BookmarkRow.swift @@ -3,6 +3,7 @@ import SwiftUI struct BookmarkRow: View { let bookmark: Bookmark var readingProgress: Double = 0 + var onPodcast: (() -> Void)? = nil var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -29,6 +30,18 @@ struct BookmarkRow: View { .font(.system(size: 16)) .foregroundStyle(.tertiary) } + + Spacer() + + if let onPodcast { + Button(action: onPodcast) { + Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") + .font(.system(size: 26)) + .foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) + } + .buttonStyle(.plain) + .padding(.top, 1) + } } if let summary = bookmark.aiSummary { @@ -76,6 +89,10 @@ struct BookmarkRow: View { .contentShape(Rectangle()) } + private var podcastCached: Bool { + FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path) + } + private var effectiveTags: [String] { let base = bookmark.tagNames let ai = bookmark.aiTags ?? [] diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 5634a68..651b8ce 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -10,17 +10,28 @@ struct BookmarksView: View { @State private var showAddBookmark = false @State private var editingBookmark: Bookmark? @State private var browsingBookmark: Bookmark? - @State private var podcastBookmark: Bookmark? + @State private var showFullPlayer = false @State private var readingProgress: [String: Double] = ReadingProgress.all() var body: some View { NavigationStack { List { ForEach(viewModel.bookmarks) { bookmark in - BookmarkRow(bookmark: bookmark, readingProgress: readingProgress[bookmark.url] ?? 0) - .onTapGesture { - browsingBookmark = bookmark + BookmarkRow( + bookmark: bookmark, + readingProgress: readingProgress[bookmark.url] ?? 0, + onPodcast: { + viewModel.podcastPlayer.start( + articleUrl: bookmark.url, + articleTitle: bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer = true } + ) + .onTapGesture { + browsingBookmark = bookmark + } .onAppear { maybeLoadMore(bookmark) } .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) .listRowSeparator(.visible) @@ -62,7 +73,12 @@ struct BookmarksView: View { Label("Open in Safari", systemImage: "safari") } Button { - podcastBookmark = bookmark + viewModel.podcastPlayer.start( + articleUrl: bookmark.url, + articleTitle: bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer = true } label: { Label("Convert to Podcast", systemImage: "headphones") } @@ -133,9 +149,19 @@ struct BookmarksView: View { } } .overlay(alignment: .bottom) { - if viewModel.enrichmentProgress > 0 { - enrichmentBanner + VStack(spacing: 8) { + if viewModel.podcastPlayer.player != nil { + MiniPlayerView(vm: viewModel.podcastPlayer) { + showFullPlayer = true + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + if viewModel.enrichmentProgress > 0 { + enrichmentBanner + } } + .animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil) + .padding(.bottom, 8) } } .sheet(isPresented: $showSettings) { @@ -158,8 +184,14 @@ struct BookmarksView: View { .onChange(of: browsingBookmark) { _, new in if new == nil { readingProgress = ReadingProgress.all() } } - .sheet(item: $podcastBookmark) { bookmark in - PodcastPlayerView(articleUrl: bookmark.url, claude: viewModel.claude) + .sheet(isPresented: $showFullPlayer) { + PodcastPlayerView( + vm: viewModel.podcastPlayer, + articleUrl: viewModel.podcastPlayer.currentArticleUrl, + articleTitle: viewModel.podcastPlayer.currentArticleTitle, + claude: viewModel.claude, + stopOnDismiss: false + ) } .refreshable { await viewModel.load() @@ -193,7 +225,6 @@ struct BookmarksView: View { .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) - .padding(.bottom, 8) } private func maybeLoadMore(_ bookmark: Bookmark) { diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index ff099c7..428bc04 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -17,6 +17,7 @@ final class BookmarksViewModel { private let api: LinkdingAPI let claude = ClaudeService() + let podcastPlayer = PodcastPlayerViewModel() private var enrichTask: Task? private let cacheFileUrl: URL diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index 7671805..c0935a6 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -202,6 +202,7 @@ struct BrowserView: View { @State private var state: BrowserState @State private var readerMode = false @State private var showPodcast = false + @State private var podcastVM = PodcastPlayerViewModel() init(url: URL, title: String, claude: ClaudeService) { self.initialURL = url @@ -290,7 +291,13 @@ struct BrowserView: View { if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) } } .sheet(isPresented: $showPodcast) { - PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude) + PodcastPlayerView( + vm: podcastVM, + articleUrl: (state.currentURL ?? initialURL).absoluteString, + articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle, + claude: claude, + stopOnDismiss: true + ) } } } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index a9c8b15..7115023 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -2,6 +2,27 @@ 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 @@ -19,19 +40,31 @@ final class PodcastPlayerViewModel { var isPlaying = false var currentTime: Double = 0 var duration: Double = 1 + var currentArticleUrl: String = "" + var currentArticleTitle: String = "" private var pollingTask: Task? private var timeObserver: Any? - private var currentTitle: String = "Marks Podcast" + 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 } + + // Teardown previous episode (saves its position) + if player != nil { stop() } + + currentArticleUrl = articleUrl + currentArticleTitle = articleTitle - 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 } + phase = .generating(progress: 0, label: "Starting…") + pollingTask = Task { do { let jobId = try await claude.generatePodcast(url: articleUrl) @@ -54,13 +87,35 @@ final class PodcastPlayerViewModel { try await Task.sleep(for: .seconds(3)) } } catch is CancellationError { - // sheet dismissed — clean exit } catch { phase = .failed(error.localizedDescription) } } } + 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) } + 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) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + func togglePlayPause() { guard let player else { return } if isPlaying { player.pause() } else { player.play() } @@ -74,21 +129,8 @@ final class PodcastPlayerViewModel { 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 + currentPodcastTitle = title try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowBluetoothA2DP]) @@ -101,6 +143,12 @@ final class PodcastPlayerViewModel { 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 { + p.seek(to: CMTime(seconds: saved, preferredTimescale: 600)) + currentTime = saved + } updateNowPlaying() } } @@ -122,17 +170,11 @@ final class PodcastPlayerViewModel { let c = MPRemoteCommandCenter.shared() c.playCommand.isEnabled = true c.playCommand.addTarget { [weak self] _ in - self?.player?.play() - self?.isPlaying = true - self?.updateNowPlaying() - return .success + 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 + self?.player?.pause(); self?.isPlaying = false; self?.updateNowPlaying(); return .success } c.changePlaybackPositionCommand.isEnabled = true c.changePlaybackPositionCommand.addTarget { [weak self] event in @@ -145,8 +187,8 @@ final class PodcastPlayerViewModel { private func updateNowPlaying() { var info: [String: Any] = [:] - info[MPMediaItemPropertyTitle] = currentTitle - info[MPMediaItemPropertyArtist] = "Marks" + 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 } @@ -165,14 +207,16 @@ final class PodcastPlayerViewModel { } } -// MARK: - View +// 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 vm = PodcastPlayerViewModel() var body: some View { NavigationStack { @@ -188,10 +232,22 @@ struct PodcastPlayerView: View { ToolbarItem(placement: .topBarLeading) { Button("Done") { dismiss() } } + if vm.player != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { vm.stop(); dismiss() } label: { + Image(systemName: "stop.circle") + .foregroundStyle(.red) + } + } + } } } - .onAppear { vm.start(articleUrl: articleUrl, claude: claude) } - .onDisappear { vm.teardown() } + .onAppear { + vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) + } + .onDisappear { + if stopOnDismiss { vm.stop() } + } } @ViewBuilder @@ -285,3 +341,86 @@ struct PodcastPlayerView: View { 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) + .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() + + 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" + } +}