From af3112530ef21656cc69891be24e56ff5858e364 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 11:37:42 -0500 Subject: [PATCH 1/5] feat(podcasts): played/unplayed queue, background generation, share-sheet audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the Podcasts tab into a proper podcast-app experience across three features that compose on a shared background-generation service. Background generation (#2) - New PodcastGenerationManager runs generate→poll→download off the player, so producing a new episode never stops current playback. - Headphone taps never interrupt: idle → generate-and-play in the foreground (unchanged); already playing → generate in the background, episode lands in the library. A "Generating…" banner surfaces active jobs. Played/unplayed + queue (#1) - PodcastEntry gains playedAt (old index.json decodes as unplayed). - Podcasts tab splits into Up Next / Played with a Play All queue that auto-advances; finishing marks played; swipe to toggle played state. Share-extension audio (#3) - "Create podcast" toggle on the save card, plus configurable Auto-Podcast Tags in Settings. The extension enqueues a request into the App Group; the main app drains it on launch/foreground and generates in the background. Closes #1 Closes #2 Closes #3 Co-Authored-By: Claude Opus 4.8 (1M context) --- Marks.xcodeproj/project.pbxproj | 10 ++ Marks/MarksApp.swift | 5 +- Marks/Services/PodcastGenerationManager.swift | 119 ++++++++++++++ Marks/Services/PodcastIndex.swift | 13 ++ Marks/Services/PodcastRequests.swift | 61 +++++++ Marks/Views/BookmarkListRow.swift | 7 +- Marks/Views/BookmarksView.swift | 41 ++++- Marks/Views/BookmarksViewModel.swift | 27 ++++ Marks/Views/BrowserView.swift | 28 +++- Marks/Views/PodcastPlayerView.swift | 149 +++++++++++++----- Marks/Views/SearchView.swift | 3 +- Marks/Views/SettingsView.swift | 17 ++ Marks/Views/TagsView.swift | 3 +- ShareExtension/ShareView.swift | 13 ++ project.yml | 2 + 15 files changed, 439 insertions(+), 59 deletions(-) create mode 100644 Marks/Services/PodcastGenerationManager.swift create mode 100644 Marks/Services/PodcastRequests.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 971d6c0..edc8656 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -20,11 +20,13 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; + 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; + 773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; }; 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; }; @@ -33,6 +35,7 @@ 852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; + 8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; @@ -110,6 +113,7 @@ 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = ""; }; 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; + 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = ""; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = ""; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = ""; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; @@ -149,6 +153,7 @@ CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = ""; }; + D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; @@ -251,7 +256,9 @@ 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, FB7728D15C17219ABFF3EFFE /* Log.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, + 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, + D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */, 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */, 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */, ); @@ -481,6 +488,7 @@ 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, + 8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */, F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */, 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */, 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */, @@ -528,8 +536,10 @@ E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, + 773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */, + 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index e6a6818..07364cc 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -65,18 +65,19 @@ struct MainContainer: View { .onOpenURL { url in handleDeepLink(url) } - .task { applyPendingIntent() } + .task { applyPendingIntent(); viewModel.processPendingPodcastRequests() } .onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() } .onChange(of: router.searchRequest) { _, _ in applyPendingIntent() } .onChange(of: scenePhase) { old, new in // Refresh when returning to the foreground (cold launch is already // covered by BookmarksView's .task, so only react to a real re-entry). if new == .active && old != .active { + viewModel.processPendingPodcastRequests() Task { await viewModel.load() } } } .sheet(item: $deepLinkBrowser) { item in - BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) + BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) } .sheet(isPresented: $showDeepLinkPlayer) { PodcastPlayerView( diff --git a/Marks/Services/PodcastGenerationManager.swift b/Marks/Services/PodcastGenerationManager.swift new file mode 100644 index 0000000..b354db5 --- /dev/null +++ b/Marks/Services/PodcastGenerationManager.swift @@ -0,0 +1,119 @@ +import Foundation +import Observation + +/// Runs podcast generation (generate → poll → download) **off** the player, so a +/// new episode can be produced in the background while another one keeps playing. +/// The finished audio is written to the on-disk cache and recorded in +/// `PodcastIndex`; the optional `onReady` callback lets a caller start playback +/// only when nothing else is playing. +@Observable +@MainActor +final class PodcastGenerationManager { + struct Job: Identifiable { + let id: String // articleUrl + var articleUrl: String + var title: String + var progress: Double // 0…1 + var label: String + var failed: Bool = false + var error: String? + } + + private(set) var jobs: [Job] = [] + + private var tasks: [String: Task] = [:] + private let claude = ClaudeService() + + /// Jobs still working (not failed). Drives the "Generating…" banner. + var activeJobs: [Job] { jobs.filter { !$0.failed } } + var hasActive: Bool { !activeJobs.isEmpty } + + func isGenerating(_ articleUrl: String) -> Bool { tasks[articleUrl] != nil } + func job(for articleUrl: String) -> Job? { jobs.first { $0.articleUrl == articleUrl } } + + /// Enqueue a background generation for `articleUrl`. Idempotent per URL. + /// If audio is already cached, `onReady` fires immediately and nothing runs. + /// `onReady` is invoked on the main actor once audio is available. + func enqueue(articleUrl: String, + title: String, + parentBookmarkUrl: String? = nil, + onReady: ((URL, String) -> Void)? = nil) { + let cached = ClaudeService.cachedPodcastURL(for: articleUrl) + if FileManager.default.fileExists(atPath: cached.path) { + onReady?(cached, title) + return + } + if tasks[articleUrl] != nil { return } // already in flight + + jobs.insert(Job(id: articleUrl, articleUrl: articleUrl, title: title, + progress: 0, label: "Starting…"), at: 0) + Analytics.track("podcast.bg_started", ["url": articleUrl]) + + let task = Task { [weak self] in + guard let self else { return } + do { + let jobId = try await claude.generatePodcast(url: articleUrl) + while !Task.isCancelled { + let status = try await claude.podcastStatus(jobId: jobId) + update(articleUrl) { + $0.progress = Double(status.progress) / 100 + $0.label = PodcastPlayerViewModel.statusLabel(status.status) + } + if status.isDone { + update(articleUrl) { $0.label = "Preparing audio…"; $0.progress = 1 } + let url = try await claude.downloadPodcastAudio( + jobId: jobId, articleUrl: articleUrl, + title: status.title, parentBookmarkUrl: parentBookmarkUrl) + let finalTitle = status.title ?? title + finish(articleUrl) + Analytics.track("podcast.bg_completed", ["url": articleUrl]) + onReady?(url, finalTitle) + return + } + if status.isFailed { + fail(articleUrl, status.error ?? "Generation failed") + return + } + try await Task.sleep(for: .seconds(3)) + } + } catch is CancellationError { + finish(articleUrl) + } catch { + fail(articleUrl, error.localizedDescription) + } + } + tasks[articleUrl] = task + } + + /// Cancel an in-flight job and drop it from the list. + func cancel(_ articleUrl: String) { + tasks[articleUrl]?.cancel() + tasks[articleUrl] = nil + jobs.removeAll { $0.articleUrl == articleUrl } + } + + func dismiss(_ articleUrl: String) { jobs.removeAll { $0.articleUrl == articleUrl } } + + // MARK: - Mutations + + private func update(_ url: String, _ mutate: (inout Job) -> Void) { + guard let i = jobs.firstIndex(where: { $0.articleUrl == url }) else { return } + mutate(&jobs[i]) + } + + private func finish(_ url: String) { + tasks[url] = nil + jobs.removeAll { $0.articleUrl == url } + } + + private func fail(_ url: String, _ message: String) { + tasks[url] = nil + update(url) { $0.failed = true; $0.error = message; $0.label = message } + Analytics.track("podcast.bg_failed", ["url": url, "error": message]) + // Failed jobs linger briefly so the user sees them, then self-clear. + Task { [weak self] in + try? await Task.sleep(for: .seconds(8)) + self?.dismiss(url) + } + } +} diff --git a/Marks/Services/PodcastIndex.swift b/Marks/Services/PodcastIndex.swift index a9c6345..23db190 100644 --- a/Marks/Services/PodcastIndex.swift +++ b/Marks/Services/PodcastIndex.swift @@ -7,6 +7,11 @@ struct PodcastEntry: Codable, Identifiable { var title: String? let createdAt: Date var parentBookmarkUrl: String? + /// When the episode was finished. `nil` means unplayed/new. Absent in + /// pre-existing index files, which decode as `nil` (i.e. unplayed). + var playedAt: Date? = nil + + var isPlayed: Bool { playedAt != nil } } enum PodcastIndex { @@ -49,6 +54,14 @@ enum PodcastIndex { all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl } } + /// Mark an episode played (default) or back to unplayed. No-op if unknown. + static func setPlayed(articleUrl: String, _ played: Bool = true) { + var entries = all() + guard let i = entries.firstIndex(where: { $0.articleUrl == articleUrl }) else { return } + entries[i].playedAt = played ? Date() : nil + save(entries) + } + static func remove(articleUrl: String) { var entries = all() entries.removeAll { $0.articleUrl == articleUrl } diff --git a/Marks/Services/PodcastRequests.swift b/Marks/Services/PodcastRequests.swift new file mode 100644 index 0000000..7128a07 --- /dev/null +++ b/Marks/Services/PodcastRequests.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Cross-process (main app ⇆ share extension) queue of podcast-generation +/// requests, plus the user's "auto-generate for these tags" rule. Lives in the +/// shared App Group so the share extension can enqueue work the main app runs — +/// the extension itself is too short-lived to generate audio. +enum PodcastRequests { + private static let appGroupId = "group.com.magicive.marks" + private static let pendingKey = "pendingPodcastRequests" + private static let autoTagsKey = "podcastAutoTags" + + private static var defaults: UserDefaults? { UserDefaults(suiteName: appGroupId) } + + struct Request: Codable { + let url: String + let title: String + } + + // MARK: - Pending queue + + /// Enqueue a request. De-duplicates on URL so repeated saves don't pile up. + static func enqueue(url: String, title: String) { + var list = pending() + guard !list.contains(where: { $0.url == url }) else { return } + list.append(Request(url: url, title: title)) + save(list) + } + + static func pending() -> [Request] { + guard let data = defaults?.data(forKey: pendingKey), + let list = try? JSONDecoder().decode([Request].self, from: data) else { return [] } + return list + } + + /// Return everything pending and clear the queue. + static func drain() -> [Request] { + let list = pending() + defaults?.removeObject(forKey: pendingKey) + return list + } + + private static func save(_ list: [Request]) { + guard let data = try? JSONEncoder().encode(list) else { return } + defaults?.set(data, forKey: pendingKey) + } + + // MARK: - Auto-tag rule + + /// Tags that auto-trigger podcast generation when a bookmark is saved. + static var autoTags: [String] { + get { defaults?.stringArray(forKey: autoTagsKey) ?? [] } + set { defaults?.set(newValue, forKey: autoTagsKey) } + } + + /// Whether saving a bookmark with `tags` should auto-generate a podcast. + static func matchesAutoTag(_ tags: [String]) -> Bool { + let auto = Set(autoTags.map { $0.lowercased() }) + guard !auto.isEmpty else { return false } + return tags.contains { auto.contains($0.lowercased()) } + } +} diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 92941ce..a5a9545 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -100,12 +100,7 @@ struct BookmarkListRow: View { claude: viewModel.claude ) showFullPlayer = true - } else { - viewModel.podcastPlayer.start( - articleUrl: bookmark.url, - articleTitle: bookmark.displayTitle, - claude: viewModel.claude - ) + } else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) { showFullPlayer = true } } diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index a2ccb98..100f816 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -146,6 +146,10 @@ struct BookmarksView: View { } .overlay(alignment: .bottom) { VStack(spacing: 8) { + if viewModel.podcastGenerator.hasActive { + podcastGeneratingBanner + .transition(.move(edge: .bottom).combined(with: .opacity)) + } if !viewModel.podcastPlayer.currentArticleUrl.isEmpty { MiniPlayerView(vm: viewModel.podcastPlayer) { showFullPlayer = true @@ -157,6 +161,7 @@ struct BookmarksView: View { } } .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty) + .animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive) .padding(.bottom, 8) } .sensoryFeedback(.selection, trigger: browsingBookmark?.id) @@ -178,7 +183,7 @@ struct BookmarksView: View { } .sheet(item: $browsingBookmark) { bookmark in if let url = URL(string: bookmark.url) { - BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) { + BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) { await viewModel.archive(bookmark) } .bookmarkOnscreen(bookmark) @@ -230,6 +235,40 @@ struct BookmarksView: View { .padding(.horizontal, 16) } + private var podcastGeneratingBanner: some View { + let jobs = viewModel.podcastGenerator.activeJobs + return HStack(spacing: 10) { + Image(systemName: "waveform") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.blue) + .symbolEffect(.variableColor.iterative, isActive: true) + VStack(alignment: .leading, spacing: 2) { + Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…") + .font(.system(size: 13, weight: .medium)) + if let first = jobs.first { + Text(first.title.isEmpty ? first.label : first.title) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 { + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(.blue) + .frame(width: 44) + } else { + ProgressView() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + } + private func maybeLoadMore(_ bookmark: Bookmark) { guard let last = viewModel.bookmarks.last, last.id == bookmark.id, viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 790a002..110b825 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -18,6 +18,7 @@ final class BookmarksViewModel { private let api: LinkdingAPI let claude = ClaudeService() let podcastPlayer = PodcastPlayerViewModel() + let podcastGenerator = PodcastGenerationManager() private var enrichTask: Task? private let cacheFileUrl: URL @@ -29,6 +30,32 @@ final class BookmarksViewModel { self.cacheFileUrl = dir.appendingPathComponent("\(name).json") } + /// True while the player is showing an episode (playing or generating one). + var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty } + + /// Handle a headphone tap without ever interrupting current playback. + /// If the player is idle, generate-and-play in the foreground and return + /// `true` (caller should present the full player). If the player is busy, + /// generate in the background — the finished episode lands in the library — + /// and return `false`. + @discardableResult + func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool { + if isPlayerBusy { + podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl) + return false + } + podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl) + return true + } + + /// Drain podcast requests queued by the share extension and start generating + /// them in the background. Safe to call repeatedly (the queue is cleared). + func processPendingPodcastRequests() { + for req in PodcastRequests.drain() { + podcastGenerator.enqueue(articleUrl: req.url, title: req.title) + } + } + func load(useCache: Bool = true) async { if useCache { loadFromCache() } isLoading = true diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index dd71346..6204abd 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -220,6 +220,7 @@ struct BrowserView: View { let initialTitle: String let claude: ClaudeService let podcastPlayer: PodcastPlayerViewModel + let podcastGenerator: PodcastGenerationManager var onArchive: (() async -> Void)? = nil @Environment(\.dismiss) private var dismiss @@ -232,11 +233,12 @@ struct BrowserView: View { @State private var navForwardCount = 0 @State private var toolbarHidden = false - init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) { + init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) { self.initialURL = url self.initialTitle = title self.claude = claude self.podcastPlayer = podcastPlayer + self.podcastGenerator = podcastGenerator self.onArchive = onArchive self._state = State(initialValue: BrowserState(url: url)) } @@ -375,13 +377,23 @@ struct BrowserView: View { Button { let currentUrl = (state.currentURL ?? initialURL).absoluteString let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle - podcastPlayer.start( - articleUrl: currentUrl, - articleTitle: currentTitle, - claude: claude, - parentBookmarkUrl: initialURL.absoluteString - ) - showPodcast = true + // Never interrupt current playback: play-and-show only when idle, + // otherwise generate in the background. + if podcastPlayer.currentArticleUrl.isEmpty { + podcastPlayer.start( + articleUrl: currentUrl, + articleTitle: currentTitle, + claude: claude, + parentBookmarkUrl: initialURL.absoluteString + ) + showPodcast = true + } else { + podcastGenerator.enqueue( + articleUrl: currentUrl, + title: currentTitle, + parentBookmarkUrl: initialURL.absoluteString + ) + } } label: { Image(systemName: "headphones") .frame(width: 44, height: 44) diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 6b44100..58eb995 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -53,6 +53,11 @@ final class PodcastPlayerViewModel { } } + /// 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 timeObserver: Any? private var endObserver: Any? @@ -66,6 +71,7 @@ final class PodcastPlayerViewModel { // Teardown previous episode (saves its position) if player != nil { stop() } + lastClaude = claude currentArticleUrl = articleUrl currentArticleTitle = articleTitle @@ -112,7 +118,16 @@ final class PodcastPlayerViewModel { } } + /// 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()) + } + func stop() { + queue = [] if !currentArticleUrl.isEmpty && currentTime > 10 { PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration) } @@ -196,16 +211,29 @@ final class PodcastPlayerViewModel { } } - // Reset to start when playback finishes, like standard media players + // 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 } - self.isPlaying = false - self.currentTime = 0 - self.player?.seek(to: .zero) - PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration) - self.updateNowPlaying() + let finished = self.currentArticleUrl + PodcastIndex.setPlayed(articleUrl: finished, true) + PodcastProgress.set(url: finished, time: 0, duration: self.duration) + + if !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` + } else { + self.isPlaying = false + self.currentTime = 0 + self.player?.seek(to: .zero) + self.updateNowPlaying() + } } // Pause on phone calls / Siri; resume when interruption ends @@ -308,7 +336,7 @@ final class PodcastPlayerViewModel { return MPMediaItemArtwork(boundsSize: size) { _ in image } }() - private static func statusLabel(_ status: String) -> String { + static func statusLabel(_ status: String) -> String { switch status { case "queued": return "Queued…" case "fetching": return "Fetching article…" @@ -618,47 +646,30 @@ struct PodcastLibraryView: View { ) } else { 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) - Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - Spacer() - 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) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - deleteEntry(entry) - } label: { - Label("Delete", systemImage: "trash") - } + if !unplayed.isEmpty { + Section("Up Next") { + ForEach(unplayed) { row(for: $0) } } } - .onDelete { indexSet in - indexSet.forEach { deleteEntry(entries[$0]) } + if !played.isEmpty { + Section("Played") { + ForEach(played) { row(for: $0) } + } } } - .listStyle(.plain) + .listStyle(.insetGrouped) } } .navigationTitle("Podcasts") .toolbar { - if !entries.isEmpty { - ToolbarItem(placement: .topBarTrailing) { EditButton() } + if !unplayed.isEmpty { + ToolbarItem(placement: .topBarLeading) { + Button { + vm.playQueue(unplayed, claude: claude) + } label: { + Label("Play All", systemImage: "play.fill") + } + } } } .overlay(alignment: .bottom) { @@ -681,6 +692,64 @@ struct PodcastLibraryView: View { } } .onAppear { entries = PodcastIndex.all() } + // 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 entries = PodcastIndex.all() } + } + + private var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } } + private var played: [PodcastEntry] { entries.filter { $0.isPlayed } } + + @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() + 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) + .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") + } + } + } + + private func setPlayed(_ entry: PodcastEntry, _ played: Bool) { + PodcastIndex.setPlayed(articleUrl: entry.articleUrl, played) + if let i = entries.firstIndex(where: { $0.articleUrl == entry.articleUrl }) { + entries[i].playedAt = played ? Date() : nil + } } private func isCurrent(_ entry: PodcastEntry) -> Bool { diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index b38f1d1..c9805de 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -48,7 +48,8 @@ struct SearchView: View { url: url, title: bookmark.displayTitle, claude: viewModel.claude, - podcastPlayer: viewModel.podcastPlayer + podcastPlayer: viewModel.podcastPlayer, + podcastGenerator: viewModel.podcastGenerator ) { await viewModel.archive(bookmark) } diff --git a/Marks/Views/SettingsView.swift b/Marks/Views/SettingsView.swift index 236cce2..45e3649 100644 --- a/Marks/Views/SettingsView.swift +++ b/Marks/Views/SettingsView.swift @@ -6,6 +6,8 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss + @State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ") + var body: some View { NavigationStack { List { @@ -15,6 +17,21 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section { + TextField("e.g. listen podcast", text: $autoTagText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: autoTagText) { _, new in + PodcastRequests.autoTags = new + .split(whereSeparator: { $0 == " " || $0 == "," }) + .map(String.init) + } + } header: { + Text("Auto-Podcast Tags") + } footer: { + Text("Saving a bookmark with any of these tags automatically generates a podcast for it.") + } + Section("Server") { Button(role: .destructive) { dismiss() diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index 524dbb9..259f203 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -76,7 +76,8 @@ struct TagBookmarksView: View { url: url, title: bookmark.displayTitle, claude: viewModel.claude, - podcastPlayer: viewModel.podcastPlayer + podcastPlayer: viewModel.podcastPlayer, + podcastGenerator: viewModel.podcastGenerator ) { await viewModel.archive(bookmark) } diff --git a/ShareExtension/ShareView.swift b/ShareExtension/ShareView.swift index 061d0f3..590f6ba 100644 --- a/ShareExtension/ShareView.swift +++ b/ShareExtension/ShareView.swift @@ -15,6 +15,7 @@ struct ShareView: View { @State private var tagText = "" @State private var notes = "" @State private var readLater = false + @State private var createPodcast = false @State private var suggestedTags: [String] = [] // linkding auto_tags @State private var knownTags: Set = [] // user's existing vocabulary (lowercased) @State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary) @@ -151,6 +152,11 @@ struct ShareView: View { Toggle("Read later", isOn: $readLater) .font(.subheadline) + + Toggle(isOn: $createPodcast) { + Label("Create podcast", systemImage: "headphones") + } + .font(.subheadline) } } @@ -236,6 +242,8 @@ struct ShareView: View { title = result.metadata?.title ?? "" suggestedTags = result.autoTags ?? [] } + // Pre-enable the toggle when the current tags match the auto-rule. + createPodcast = PodcastRequests.matchesAutoTag(parsedTags) phase = .editing await loadSuggestions() } catch { @@ -294,6 +302,11 @@ struct ShareView: View { ) _ = try await api.createBookmark(create) } + // Hand off podcast generation to the main app (the extension is too + // short-lived to generate audio itself). + if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) { + PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title) + } phase = .done try? await Task.sleep(nanoseconds: 700_000_000) onClose(true) diff --git a/project.yml b/project.yml index a235e3d..940ccfe 100644 --- a/project.yml +++ b/project.yml @@ -90,6 +90,8 @@ targets: - path: Marks/Services/LinkdingAPI.swift - path: Marks/Services/MarksAuth.swift - path: Marks/Services/Log.swift + # Cross-process podcast request queue: the extension enqueues, the app runs it. + - path: Marks/Services/PodcastRequests.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension From 5e69d99996601e4ddb3461a66f52c866e9c467d2 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 12:01:57 -0500 Subject: [PATCH 2/5] feat(podcasts): surface mark-played in context menu, full player, and row badge - Add a long-press context menu on each library row (Play/Pause, Mark as Played/Unplayed, Delete). - Add a Mark as Played/Unplayed control next to Share Episode in the full player, tracking the loaded episode across queue advances. - Show a green checkmark on played rows, on top of the existing Played section + dimmed title, so played state reads at a glance. Co-Authored-By: Claude Opus 4.8 (1M context) --- Marks/Services/PodcastIndex.swift | 4 ++ Marks/Views/PodcastPlayerView.swift | 62 +++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/Marks/Services/PodcastIndex.swift b/Marks/Services/PodcastIndex.swift index 23db190..a340bcd 100644 --- a/Marks/Services/PodcastIndex.swift +++ b/Marks/Services/PodcastIndex.swift @@ -54,6 +54,10 @@ enum PodcastIndex { all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl } } + static func isPlayed(articleUrl: String) -> Bool { + all().first { $0.articleUrl == articleUrl }?.isPlayed ?? false + } + /// Mark an episode played (default) or back to unplayed. No-op if unknown. static func setPlayed(articleUrl: String, _ played: Bool = true) { var entries = all() diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 58eb995..df51ba4 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -358,6 +358,13 @@ struct PodcastPlayerView: View { 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 + } var body: some View { NavigationStack { @@ -385,6 +392,10 @@ struct PodcastPlayerView: View { } .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() } @@ -495,11 +506,25 @@ struct PodcastPlayerView: View { .glassEffect(in: Capsule()) } - 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") + HStack(spacing: 28) { + Button { + isPlayed.toggle() + PodcastIndex.setPlayed(articleUrl: 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(.secondary) + .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) + } } } } @@ -713,6 +738,12 @@ struct PodcastLibraryView: View { .foregroundStyle(.secondary) } Spacer() + if entry.isPlayed { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 15)) + .foregroundStyle(.green) + .accessibilityLabel("Played") + } Button { play(entry) } label: { @@ -743,6 +774,29 @@ struct PodcastLibraryView: View { Label("Delete", systemImage: "trash") } } + .contextMenu { + Button { + play(entry) + } label: { + Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play", + systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play") + } + 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) { From d2ccbc94b5c455eac3e843f2644361700cb3ec22 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 13:07:31 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat(podcasts):=20#6=20quick=20wins=20?= =?UTF-8?q?=E2=80=94=20tab=20badge,=20remember=20speed,=20sleep=20timer,?= =?UTF-8?q?=20persist=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unread badge on the Podcasts tab via a new observable PodcastLibrary store that mirrors PodcastIndex; all played/remove/generation mutations route through it so the count stays live. - Persist playback speed across sessions (UserDefaults), applied on setup. - Sleep timer (15/30/45 min or end-of-episode) with a moon menu in the full player; end-of-episode stops instead of auto-advancing. - Persist the current episode + Up Next queue and restore a *paused* session on launch; save position when backgrounding. Audio session is only claimed on actual playback so a restored session never interrupts other audio. Co-Authored-By: Claude Opus 4.8 (1M context) --- Marks.xcodeproj/project.pbxproj | 4 + Marks/MarksApp.swift | 13 +- Marks/Services/PodcastGenerationManager.swift | 1 + Marks/Services/PodcastLibrary.swift | 31 +++ Marks/Views/PodcastPlayerView.swift | 209 +++++++++++++++--- 5 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 Marks/Services/PodcastLibrary.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index edc8656..98a2ae7 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; }; E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; }; + E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */; }; E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; }; @@ -113,6 +114,7 @@ 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = ""; }; 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; + 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = ""; }; 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = ""; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = ""; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = ""; }; @@ -258,6 +260,7 @@ F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, + 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */, D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */, 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */, 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */, @@ -538,6 +541,7 @@ 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, 773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, + E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */, 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */, 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 07364cc..8afcd53 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -38,6 +38,7 @@ struct MainContainer: View { @State private var showDeepLinkPlayer = false @State private var selectedTab: AppTab = .bookmarks @State private var router = IntentRouter.shared + @State private var library = PodcastLibrary.shared @Environment(\.scenePhase) private var scenePhase init(config: ServerConfig, onDisconnect: @escaping () -> Void) { @@ -58,6 +59,7 @@ struct MainContainer: View { Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) { PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) } + .badge(library.unplayedCount) Tab(value: AppTab.search, role: .search) { SearchView(viewModel: viewModel) } @@ -65,7 +67,11 @@ struct MainContainer: View { .onOpenURL { url in handleDeepLink(url) } - .task { applyPendingIntent(); viewModel.processPendingPodcastRequests() } + .task { + applyPendingIntent() + viewModel.processPendingPodcastRequests() + viewModel.podcastPlayer.restoreSession(claude: viewModel.claude) + } .onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() } .onChange(of: router.searchRequest) { _, _ in applyPendingIntent() } .onChange(of: scenePhase) { old, new in @@ -75,6 +81,11 @@ struct MainContainer: View { viewModel.processPendingPodcastRequests() Task { await viewModel.load() } } + // Persist playback position + session when leaving the foreground so a + // relaunch can resume where you left off. + if new == .background { + viewModel.podcastPlayer.saveProgress() + } } .sheet(item: $deepLinkBrowser) { item in BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) diff --git a/Marks/Services/PodcastGenerationManager.swift b/Marks/Services/PodcastGenerationManager.swift index b354db5..57eb079 100644 --- a/Marks/Services/PodcastGenerationManager.swift +++ b/Marks/Services/PodcastGenerationManager.swift @@ -66,6 +66,7 @@ final class PodcastGenerationManager { title: status.title, parentBookmarkUrl: parentBookmarkUrl) let finalTitle = status.title ?? title finish(articleUrl) + PodcastLibrary.shared.reload() // new episode → refresh list + badge Analytics.track("podcast.bg_completed", ["url": articleUrl]) onReady?(url, finalTitle) return diff --git a/Marks/Services/PodcastLibrary.swift b/Marks/Services/PodcastLibrary.swift new file mode 100644 index 0000000..a344373 --- /dev/null +++ b/Marks/Services/PodcastLibrary.swift @@ -0,0 +1,31 @@ +import Foundation +import Observation + +/// Observable, in-memory mirror of `PodcastIndex` so SwiftUI updates immediately +/// on changes — the Podcasts tab badge and the library list both read from here. +/// Route podcast mutations through this store so the unplayed badge stays in sync. +@Observable +@MainActor +final class PodcastLibrary { + static let shared = PodcastLibrary() + + private(set) var entries: [PodcastEntry] = [] + + var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } } + var played: [PodcastEntry] { entries.filter { $0.isPlayed } } + var unplayedCount: Int { entries.reduce(0) { $0 + ($1.isPlayed ? 0 : 1) } } + + private init() { entries = PodcastIndex.all() } + + func reload() { entries = PodcastIndex.all() } + + func setPlayed(_ articleUrl: String, _ played: Bool) { + PodcastIndex.setPlayed(articleUrl: articleUrl, played) + reload() + } + + func remove(_ articleUrl: String) { + PodcastIndex.remove(articleUrl: articleUrl) + reload() + } +} diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index df51ba4..fb084bd 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -42,10 +42,21 @@ final class PodcastPlayerViewModel { var duration: Double = 1 var currentArticleUrl: String = "" var currentArticleTitle: String = "" - var playbackSpeed: Float = 1.0 + 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 @@ -59,6 +70,7 @@ final class PodcastPlayerViewModel { private var lastClaude: ClaudeService? private var pollingTask: Task? + private var sleepTask: Task? private var timeObserver: Any? private var endObserver: Any? private var interruptionObserver: Any? @@ -124,10 +136,14 @@ final class PodcastPlayerViewModel { // `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) } @@ -161,6 +177,7 @@ final class PodcastPlayerViewModel { if isPlaying { player.pause() } else { + try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session player.rate = playbackSpeed } isPlaying.toggle() @@ -183,16 +200,90 @@ final class PodcastPlayerViewModel { func setPlaybackSpeed(_ speed: Float) { playbackSpeed = speed + UserDefaults.standard.set(Double(speed), forKey: Self.speedKey) if isPlaying { player?.rate = speed } updateNowPlaying() } - private func setupPlayer(url: URL, title: String) { + // 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]) - try? AVAudioSession.sharedInstance().setActive(true) + // 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) @@ -220,19 +311,26 @@ final class PodcastPlayerViewModel { let finished = self.currentArticleUrl PodcastIndex.setPlayed(articleUrl: finished, true) PodcastProgress.set(url: finished, time: 0, duration: self.duration) + PodcastLibrary.shared.reload() - if !self.queue.isEmpty { + // 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() } } @@ -273,14 +371,20 @@ final class PodcastPlayerViewModel { setupRemoteCommands() phase = .ready(title: title) updateNowPlaying() - p.rate = playbackSpeed - isPlaying = true + 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 @@ -366,6 +470,26 @@ struct PodcastPlayerView: View { 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 { @@ -484,32 +608,49 @@ struct PodcastPlayerView: View { } } - 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) + 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()) } - } 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()) } HStack(spacing: 28) { Button { isPlayed.toggle() - PodcastIndex.setPlayed(articleUrl: activeUrl, isPlayed) + PodcastLibrary.shared.setPlayed(activeUrl, isPlayed) } label: { Label(isPlayed ? "Mark as Unplayed" : "Mark as Played", systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle") @@ -657,13 +798,13 @@ struct PodcastLibraryView: View { let vm: PodcastPlayerViewModel let claude: ClaudeService - @State private var entries: [PodcastEntry] = [] + @State private var library = PodcastLibrary.shared @State private var showFullPlayer = false var body: some View { NavigationStack { Group { - if entries.isEmpty { + if library.entries.isEmpty { ContentUnavailableView( "No Podcasts", systemImage: "headphones", @@ -716,14 +857,14 @@ struct PodcastLibraryView: View { ) } } - .onAppear { entries = PodcastIndex.all() } + .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 entries = PodcastIndex.all() } + .onChange(of: vm.currentArticleUrl) { _, _ in library.reload() } } - private var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } } - private var played: [PodcastEntry] { entries.filter { $0.isPlayed } } + private var unplayed: [PodcastEntry] { library.unplayed } + private var played: [PodcastEntry] { library.played } @ViewBuilder private func row(for entry: PodcastEntry) -> some View { @@ -800,10 +941,7 @@ struct PodcastLibraryView: View { } private func setPlayed(_ entry: PodcastEntry, _ played: Bool) { - PodcastIndex.setPlayed(articleUrl: entry.articleUrl, played) - if let i = entries.firstIndex(where: { $0.articleUrl == entry.articleUrl }) { - entries[i].playedAt = played ? Date() : nil - } + library.setPlayed(entry.articleUrl, played) } private func isCurrent(_ entry: PodcastEntry) -> Bool { @@ -819,8 +957,7 @@ struct PodcastLibraryView: View { } private func deleteEntry(_ entry: PodcastEntry) { - PodcastIndex.remove(articleUrl: entry.articleUrl) - entries.removeAll { $0.articleUrl == entry.articleUrl } + library.remove(entry.articleUrl) if vm.currentArticleUrl == entry.articleUrl { vm.stop() } } } From a155c15e179d140834d89a0c16ec55a116edef51 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 13:11:46 -0500 Subject: [PATCH 4/5] feat(podcasts): #8 episode detail sheet (source, played state, actions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New EpisodeDetailView: title, source domain, created date, played badge, and per-episode actions — Play/Pause, Mark Played/Unplayed, Open Source, Share, Delete — all routed through the PodcastLibrary store so the badge stays in sync. - Reachable by tapping a row (or "Episode Details" in the context menu) in the Podcasts tab, and by tapping a row in the episode picker. Note: AI summary is intentionally omitted for now — it's keyed by bookmark id (not URL), so surfacing it would recouple the deliberately-decoupled Podcasts tab to BookmarksViewModel. Open Source uses Safari rather than the in-app browser to avoid sheet-stacking. Both are noted as follow-ups on issue #8. Co-Authored-By: Claude Opus 4.8 (1M context) --- Marks/Views/PodcastPlayerView.swift | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index fb084bd..5dcb013 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -800,6 +800,7 @@ struct PodcastLibraryView: View { @State private var library = PodcastLibrary.shared @State private var showFullPlayer = false + @State private var detailEntry: PodcastEntry? var body: some View { NavigationStack { @@ -856,6 +857,9 @@ struct PodcastLibraryView: View { 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), @@ -896,6 +900,8 @@ struct PodcastLibraryView: View { .buttonStyle(.plain) } .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { detailEntry = entry } .swipeActions(edge: .leading) { Button { setPlayed(entry, !entry.isPlayed) @@ -922,6 +928,11 @@ struct PodcastLibraryView: View { 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: { @@ -970,6 +981,7 @@ struct EpisodePickerView: View { let claude: ClaudeService @State private var entries: [PodcastEntry] = [] + @State private var detailEntry: PodcastEntry? @Environment(\.dismiss) private var dismiss var body: some View { @@ -1005,6 +1017,8 @@ struct EpisodePickerView: View { .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") @@ -1020,6 +1034,9 @@ struct EpisodePickerView: View { Button("Done") { dismiss() } } } + .sheet(item: $detailEntry) { entry in + EpisodeDetailView(entry: entry, vm: vm, claude: claude) + } } .onAppear { entries = PodcastIndex.find(for: bookmark.url) } } @@ -1030,3 +1047,103 @@ struct EpisodePickerView: View { 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() } + } + } + } + } +} From 1f49a7dcbede6aa8a024fa150af911f7afe4e480 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 13:16:16 -0500 Subject: [PATCH 5/5] =?UTF-8?q?feat(podcasts):=20#8=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20AI=20summary=20+=20in-app=20Open=20Bookmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the two deviations from the episode detail sheet: - AI summary now shows in the detail sheet. Added a URL-keyed AISummaryStore that BookmarksViewModel populates as it enriches/loads bookmarks, so the decoupled Podcasts tab can surface a summary from just the episode URL — without recoupling to BookmarksViewModel. - "Open Bookmark" opens the in-app BrowserView as a nested sheet (generator threaded through PodcastLibraryView / EpisodePickerView), alongside the existing "Open in Safari" and Share. Co-Authored-By: Claude Opus 4.8 (1M context) --- Marks.xcodeproj/project.pbxproj | 4 ++++ Marks/MarksApp.swift | 2 +- Marks/Services/AISummaryStore.swift | 27 +++++++++++++++++++++ Marks/Views/BookmarkListRow.swift | 2 +- Marks/Views/BookmarksViewModel.swift | 4 ++++ Marks/Views/PodcastPlayerView.swift | 36 +++++++++++++++++++++++++--- 6 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 Marks/Services/AISummaryStore.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index 98a2ae7..9b661ba 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; + 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; @@ -156,6 +157,7 @@ CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = ""; }; D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = ""; }; D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = ""; }; + D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; @@ -251,6 +253,7 @@ 7ABBFDF43A1EB773E0A49CFE /* Services */ = { isa = PBXGroup; children = ( + D2860AAA865515225FB9FC65 /* AISummaryStore.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */, C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */, @@ -515,6 +518,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 8afcd53..19dfb05 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -57,7 +57,7 @@ struct MainContainer: View { TagsView(viewModel: viewModel) } Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) { - PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) + PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) } .badge(library.unplayedCount) Tab(value: AppTab.search, role: .search) { diff --git a/Marks/Services/AISummaryStore.swift b/Marks/Services/AISummaryStore.swift new file mode 100644 index 0000000..84922e5 --- /dev/null +++ b/Marks/Services/AISummaryStore.swift @@ -0,0 +1,27 @@ +import Foundation + +/// URL-keyed cache of AI summaries so views that only know a URL — e.g. the +/// deliberately-decoupled Podcasts tab — can show a bookmark's summary without +/// holding the full `Bookmark`. Written by `BookmarksViewModel` as it enriches +/// and loads bookmarks; read by the episode detail sheet. +enum AISummaryStore { + private static let key = "aiSummaryByURL" + + static func summary(for url: String) -> String? { + let store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:] + let value = store[url] + return (value?.isEmpty ?? true) ? nil : value + } + + static func set(_ summary: String?, for url: String) { + var store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:] + if let summary, !summary.isEmpty { + guard store[url] != summary else { return } // no-op if unchanged + store[url] = summary + } else { + guard store[url] != nil else { return } + store.removeValue(forKey: url) + } + UserDefaults.standard.set(store, forKey: key) + } +} diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index a5a9545..1e19e2e 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -85,7 +85,7 @@ struct BookmarkListRow: View { ) } .sheet(item: $episodePickerBookmark) { b in - EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude) + EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) } } diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 110b825..80a662e 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -299,6 +299,8 @@ final class BookmarksViewModel { "tags": bookmark.aiTags ?? [] ] UserDefaults.standard.set(store, forKey: "aiData") + // Mirror by URL so the decoupled Podcasts tab can show summaries. + AISummaryStore.set(bookmark.aiSummary, for: bookmark.url) } private func restoreAIData() { @@ -311,6 +313,8 @@ final class BookmarksViewModel { if let d = store["\(list[i].id)"] { list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 } list[i].aiTags = d["tags"] as? [String] + // Keep the URL-keyed summary cache populated for existing bookmarks. + if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) } } } } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 5dcb013..778df8c 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -797,6 +797,7 @@ struct MiniPlayerView: View { struct PodcastLibraryView: View { let vm: PodcastPlayerViewModel let claude: ClaudeService + let podcastGenerator: PodcastGenerationManager @State private var library = PodcastLibrary.shared @State private var showFullPlayer = false @@ -858,7 +859,7 @@ struct PodcastLibraryView: View { ) } .sheet(item: $detailEntry) { entry in - EpisodeDetailView(entry: entry, vm: vm, claude: claude) + EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator) } } .onAppear { library.reload() } @@ -979,6 +980,7 @@ struct EpisodePickerView: View { let bookmark: Bookmark let vm: PodcastPlayerViewModel let claude: ClaudeService + let podcastGenerator: PodcastGenerationManager @State private var entries: [PodcastEntry] = [] @State private var detailEntry: PodcastEntry? @@ -1035,7 +1037,7 @@ struct EpisodePickerView: View { } } .sheet(item: $detailEntry) { entry in - EpisodeDetailView(entry: entry, vm: vm, claude: claude) + EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator) } } .onAppear { entries = PodcastIndex.find(for: bookmark.url) } @@ -1057,10 +1059,12 @@ struct EpisodeDetailView: View { let entry: PodcastEntry let vm: PodcastPlayerViewModel let claude: ClaudeService + let podcastGenerator: PodcastGenerationManager @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var library = PodcastLibrary.shared + @State private var showBrowser = false private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl } private var isPlayed: Bool { @@ -1068,6 +1072,10 @@ struct EpisodeDetailView: View { } private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) } private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl } + private var summary: String? { + AISummaryStore.summary(for: entry.parentBookmarkUrl ?? entry.articleUrl) + ?? AISummaryStore.summary(for: entry.articleUrl) + } var body: some View { NavigationStack { @@ -1094,6 +1102,14 @@ struct EpisodeDetailView: View { .padding(.vertical, 4) } + if let summary { + Section("Summary") { + Text(summary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Section { Button { if isCurrent { @@ -1116,10 +1132,15 @@ struct EpisodeDetailView: View { if let sourceURL { Section { + Button { + showBrowser = true + } label: { + Label("Open Bookmark", systemImage: "book") + } Button { openURL(sourceURL) } label: { - Label("Open Source", systemImage: "safari") + Label("Open in Safari", systemImage: "safari") } ShareLink(item: sourceURL) { Label("Share Episode", systemImage: "square.and.arrow.up") @@ -1144,6 +1165,15 @@ struct EpisodeDetailView: View { Button("Done") { dismiss() } } } + .sheet(isPresented: $showBrowser) { + if let sourceURL { + BrowserView(url: sourceURL, + title: entry.title ?? domain, + claude: claude, + podcastPlayer: vm, + podcastGenerator: podcastGenerator) + } + } } } }