feat(podcasts): add a Podcasts tab to list and play on demand
All checks were successful
CI / build-and-deploy (push) Successful in 15s
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user