From d2ccbc94b5c455eac3e843f2644361700cb3ec22 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Wed, 1 Jul 2026 13:07:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20#6=20quick=20wins=20=E2=80=94?= =?UTF-8?q?=20tab=20badge,=20remember=20speed,=20sleep=20timer,=20persist?= =?UTF-8?q?=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() } } }