feat(podcasts): add a Podcasts tab to list and play on demand
All checks were successful
CI / build-and-deploy (push) Successful in 15s

Surface every podcast in PodcastIndex as a top-level tab instead of a
buried sheet. Repurpose PodcastLibraryView into the tab: play/pause toggle
per episode, a now-playing bar that opens the full player, swipe/Edit to
delete. Uses the shared podcastPlayer so playback state is consistent
across tabs. Removes the redundant Podcasts entry from the bookmarks menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-06-24 13:16:49 -05:00
parent 64a944ab10
commit f8de444f8f
4 changed files with 44 additions and 20 deletions

View File

@@ -3,7 +3,7 @@ import AppIntents
/// Which top-level tab the app is showing. Used so an intent can switch tabs. /// Which top-level tab the app is showing. Used so an intent can switch tabs.
enum AppTab: Hashable { enum AppTab: Hashable {
case bookmarks, tags, search case bookmarks, tags, podcasts, search
} }
/// Bridges App Intents (which run in the main app process, since there is no /// Bridges App Intents (which run in the main app process, since there is no

View File

@@ -55,6 +55,9 @@ struct MainContainer: View {
Tab("Tags", systemImage: "tag", value: AppTab.tags) { Tab("Tags", systemImage: "tag", value: AppTab.tags) {
TagsView(viewModel: viewModel) TagsView(viewModel: viewModel)
} }
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
Tab(value: AppTab.search, role: .search) { Tab(value: AppTab.search, role: .search) {
SearchView(viewModel: viewModel) SearchView(viewModel: viewModel)
} }

View File

@@ -58,7 +58,6 @@ struct BookmarksView: View {
@State private var editingBookmark: Bookmark? @State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark? @State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var showPodcastLibrary = false
@State private var showAsk = false @State private var showAsk = false
@State private var readingProgress: [String: Double] = ReadingProgress.all() @State private var readingProgress: [String: Double] = ReadingProgress.all()
@@ -112,11 +111,6 @@ struct BookmarksView: View {
} label: { } label: {
Label("Enrich All with AI", systemImage: "wand.and.stars") Label("Enrich All with AI", systemImage: "wand.and.stars")
} }
Button {
showPodcastLibrary = true
} label: {
Label("Podcasts", systemImage: "headphones")
}
} label: { } label: {
Image(systemName: "sparkles") Image(systemName: "sparkles")
} }
@@ -202,9 +196,6 @@ struct BookmarksView: View {
stopOnDismiss: false stopOnDismiss: false
) )
} }
.sheet(isPresented: $showPodcastLibrary) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
.refreshable { .refreshable {
await viewModel.load() await viewModel.load()
} }

View File

@@ -597,12 +597,15 @@ struct MiniPlayerView: View {
// MARK: - Podcast library // MARK: - Podcast library
/// The Podcasts tab: lists every podcast generated in the app (from
/// `PodcastIndex`) and plays them on demand through the shared player, with a
/// now-playing bar that opens the full player.
struct PodcastLibraryView: View { struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel let vm: PodcastPlayerViewModel
let claude: ClaudeService let claude: ClaudeService
@State private var entries: [PodcastEntry] = [] @State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss @State private var showFullPlayer = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -611,7 +614,7 @@ struct PodcastLibraryView: View {
ContentUnavailableView( ContentUnavailableView(
"No Podcasts", "No Podcasts",
systemImage: "headphones", systemImage: "headphones",
description: Text("Podcasts you generate will appear here.") description: Text("Podcasts you generate from bookmarks will appear here.")
) )
} else { } else {
List { List {
@@ -627,14 +630,12 @@ struct PodcastLibraryView: View {
} }
Spacer() Spacer()
Button { Button {
vm.start(articleUrl: entry.articleUrl, play(entry)
articleTitle: entry.title ?? "",
claude: claude)
dismiss()
} label: { } label: {
Image(systemName: "play.circle.fill") Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36)) .font(.system(size: 36))
.foregroundStyle(.blue) .foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -655,16 +656,45 @@ struct PodcastLibraryView: View {
} }
} }
.navigationTitle("Podcasts") .navigationTitle("Podcasts")
.navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { if !entries.isEmpty {
Button("Done") { dismiss() } ToolbarItem(placement: .topBarTrailing) { EditButton() }
} }
} }
.overlay(alignment: .bottom) {
if !vm.currentArticleUrl.isEmpty {
MiniPlayerView(vm: vm) { showFullPlayer = true }
.padding(.horizontal)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.spring(duration: 0.3), value: vm.currentArticleUrl.isEmpty)
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: vm,
articleUrl: vm.currentArticleUrl,
articleTitle: vm.currentArticleTitle,
claude: claude,
stopOnDismiss: false
)
}
} }
.onAppear { entries = PodcastIndex.all() } .onAppear { entries = PodcastIndex.all() }
} }
private func isCurrent(_ entry: PodcastEntry) -> Bool {
vm.currentArticleUrl == entry.articleUrl
}
private func play(_ entry: PodcastEntry) {
if isCurrent(entry) {
vm.togglePlayPause()
} else {
vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? "", claude: claude)
}
}
private func deleteEntry(_ entry: PodcastEntry) { private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl) PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl } entries.removeAll { $0.articleUrl == entry.articleUrl }