feat(podcasts): played/unplayed queue, background generation, share-sheet audio #4
@@ -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 = "<group>"; };
|
||||
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
|
||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
31
Marks/Services/PodcastLibrary.swift
Normal file
31
Marks/Services/PodcastLibrary.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
private var sleepTask: Task<Void, Never>?
|
||||
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()
|
||||
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,6 +608,7 @@ struct PodcastPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Menu {
|
||||
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
|
||||
Button {
|
||||
@@ -506,10 +631,26 @@ struct PodcastPlayerView: View {
|
||||
.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())
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user