From af3112530ef21656cc69891be24e56ff5858e364 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 11:37:42 -0500 Subject: [PATCH] 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