feat(podcasts): played/unplayed queue, background generation, share-sheet audio #4

Merged
admin merged 5 commits from feat/podcast-app-experience into master 2026-07-01 18:18:57 +00:00
5 changed files with 221 additions and 37 deletions
Showing only changes of commit d2ccbc94b5 - Show all commits

View File

@@ -59,6 +59,7 @@
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; }; DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.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 */; }; E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
@@ -258,6 +260,7 @@
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */, 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */, D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */, 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */, 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
@@ -538,6 +541,7 @@
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */, 773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */, 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */, 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,

View File

@@ -38,6 +38,7 @@ struct MainContainer: View {
@State private var showDeepLinkPlayer = false @State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks @State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared @State private var router = IntentRouter.shared
@State private var library = PodcastLibrary.shared
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) { init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
@@ -58,6 +59,7 @@ struct MainContainer: View {
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) { Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
} }
.badge(library.unplayedCount)
Tab(value: AppTab.search, role: .search) { Tab(value: AppTab.search, role: .search) {
SearchView(viewModel: viewModel) SearchView(viewModel: viewModel)
} }
@@ -65,7 +67,11 @@ struct MainContainer: View {
.onOpenURL { url in .onOpenURL { url in
handleDeepLink(url) 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.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() } .onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.onChange(of: scenePhase) { old, new in .onChange(of: scenePhase) { old, new in
@@ -75,6 +81,11 @@ struct MainContainer: View {
viewModel.processPendingPodcastRequests() viewModel.processPendingPodcastRequests()
Task { await viewModel.load() } 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 .sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)

View File

@@ -66,6 +66,7 @@ final class PodcastGenerationManager {
title: status.title, parentBookmarkUrl: parentBookmarkUrl) title: status.title, parentBookmarkUrl: parentBookmarkUrl)
let finalTitle = status.title ?? title let finalTitle = status.title ?? title
finish(articleUrl) finish(articleUrl)
PodcastLibrary.shared.reload() // new episode refresh list + badge
Analytics.track("podcast.bg_completed", ["url": articleUrl]) Analytics.track("podcast.bg_completed", ["url": articleUrl])
onReady?(url, finalTitle) onReady?(url, finalTitle)
return return

View 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()
}
}

View File

@@ -42,10 +42,21 @@ final class PodcastPlayerViewModel {
var duration: Double = 1 var duration: Double = 1
var currentArticleUrl: String = "" var currentArticleUrl: String = ""
var currentArticleTitle: 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] 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 { var isGenerating: Bool {
switch phase { switch phase {
case .generating, .downloading: return true case .generating, .downloading: return true
@@ -59,6 +70,7 @@ final class PodcastPlayerViewModel {
private var lastClaude: ClaudeService? private var lastClaude: ClaudeService?
private var pollingTask: Task<Void, Never>? private var pollingTask: Task<Void, Never>?
private var sleepTask: Task<Void, Never>?
private var timeObserver: Any? private var timeObserver: Any?
private var endObserver: Any? private var endObserver: Any?
private var interruptionObserver: Any? private var interruptionObserver: Any?
@@ -124,10 +136,14 @@ final class PodcastPlayerViewModel {
// `start` calls `stop`, which clears the queue set it afterwards. // `start` calls `stop`, which clears the queue set it afterwards.
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude) start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
queue = Array(entries.dropFirst()) queue = Array(entries.dropFirst())
persistSession()
} }
func stop() { func stop() {
queue = [] queue = []
clearSession()
sleepTask?.cancel(); sleepTask = nil
sleepTimer = .off
if !currentArticleUrl.isEmpty && currentTime > 10 { if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration) PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
} }
@@ -161,6 +177,7 @@ final class PodcastPlayerViewModel {
if isPlaying { if isPlaying {
player.pause() player.pause()
} else { } else {
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
player.rate = playbackSpeed player.rate = playbackSpeed
} }
isPlaying.toggle() isPlaying.toggle()
@@ -183,16 +200,90 @@ final class PodcastPlayerViewModel {
func setPlaybackSpeed(_ speed: Float) { func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed playbackSpeed = speed
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
if isPlaying { player?.rate = speed } if isPlaying { player?.rate = speed }
updateNowPlaying() 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 currentPodcastTitle = title
// A freshly generated episode may have just landed in the index.
PodcastLibrary.shared.reload()
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
options: [.allowBluetoothA2DP]) 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 item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item) let p = AVPlayer(playerItem: item)
@@ -220,19 +311,26 @@ final class PodcastPlayerViewModel {
let finished = self.currentArticleUrl let finished = self.currentArticleUrl
PodcastIndex.setPlayed(articleUrl: finished, true) PodcastIndex.setPlayed(articleUrl: finished, true)
PodcastProgress.set(url: finished, time: 0, duration: self.duration) 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 next = self.queue.removeFirst()
let remaining = self.queue let remaining = self.queue
let claude = self.lastClaude ?? ClaudeService() let claude = self.lastClaude ?? ClaudeService()
self.start(articleUrl: next.articleUrl, self.start(articleUrl: next.articleUrl,
articleTitle: next.title ?? "", claude: claude) articleTitle: next.title ?? "", claude: claude)
self.queue = remaining // `start` cleared it via `stop` self.queue = remaining // `start` cleared it via `stop`
self.persistSession()
} else { } else {
self.isPlaying = false self.isPlaying = false
self.currentTime = 0 self.currentTime = 0
self.player?.seek(to: .zero) self.player?.seek(to: .zero)
self.updateNowPlaying() self.updateNowPlaying()
self.persistSession()
} }
} }
@@ -273,14 +371,20 @@ final class PodcastPlayerViewModel {
setupRemoteCommands() setupRemoteCommands()
phase = .ready(title: title) phase = .ready(title: title)
updateNowPlaying() updateNowPlaying()
if autoPlay {
p.rate = playbackSpeed p.rate = playbackSpeed
isPlaying = true isPlaying = true
} else {
isPlaying = false
}
persistSession()
} }
private func setupRemoteCommands() { private func setupRemoteCommands() {
let c = MPRemoteCommandCenter.shared() let c = MPRemoteCommandCenter.shared()
c.playCommand.isEnabled = true c.playCommand.isEnabled = true
c.playCommand.addTarget { [weak self] _ in c.playCommand.addTarget { [weak self] _ in
try? AVAudioSession.sharedInstance().setActive(true)
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
} }
c.pauseCommand.isEnabled = true c.pauseCommand.isEnabled = true
@@ -366,6 +470,26 @@ struct PodcastPlayerView: View {
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl 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 { var body: some View {
NavigationStack { NavigationStack {
VStack { VStack {
@@ -484,6 +608,7 @@ struct PodcastPlayerView: View {
} }
} }
HStack(spacing: 12) {
Menu { Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button { Button {
@@ -506,10 +631,26 @@ struct PodcastPlayerView: View {
.glassEffect(in: Capsule()) .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) { HStack(spacing: 28) {
Button { Button {
isPlayed.toggle() isPlayed.toggle()
PodcastIndex.setPlayed(articleUrl: activeUrl, isPlayed) PodcastLibrary.shared.setPlayed(activeUrl, isPlayed)
} label: { } label: {
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played", Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle") systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
@@ -657,13 +798,13 @@ struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel let vm: PodcastPlayerViewModel
let claude: ClaudeService let claude: ClaudeService
@State private var entries: [PodcastEntry] = [] @State private var library = PodcastLibrary.shared
@State private var showFullPlayer = false @State private var showFullPlayer = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
if entries.isEmpty { if library.entries.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"No Podcasts", "No Podcasts",
systemImage: "headphones", 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), // When the current episode changes (finished, advanced, or stopped),
// re-read the index so played items move into the Played section. // 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 unplayed: [PodcastEntry] { library.unplayed }
private var played: [PodcastEntry] { entries.filter { $0.isPlayed } } private var played: [PodcastEntry] { library.played }
@ViewBuilder @ViewBuilder
private func row(for entry: PodcastEntry) -> some View { private func row(for entry: PodcastEntry) -> some View {
@@ -800,10 +941,7 @@ struct PodcastLibraryView: View {
} }
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) { private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
PodcastIndex.setPlayed(articleUrl: entry.articleUrl, played) library.setPlayed(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 { private func isCurrent(_ entry: PodcastEntry) -> Bool {
@@ -819,8 +957,7 @@ struct PodcastLibraryView: View {
} }
private func deleteEntry(_ entry: PodcastEntry) { private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl) library.remove(entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
} }
} }