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
Showing only changes of commit a155c15e17 - Show all commits

View File

@@ -800,6 +800,7 @@ struct PodcastLibraryView: View {
@State private var library = PodcastLibrary.shared @State private var library = PodcastLibrary.shared
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var detailEntry: PodcastEntry?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -856,6 +857,9 @@ struct PodcastLibraryView: View {
stopOnDismiss: false stopOnDismiss: false
) )
} }
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude)
}
} }
.onAppear { library.reload() } .onAppear { library.reload() }
// When the current episode changes (finished, advanced, or stopped), // When the current episode changes (finished, advanced, or stopped),
@@ -896,6 +900,8 @@ struct PodcastLibraryView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .leading) { .swipeActions(edge: .leading) {
Button { Button {
setPlayed(entry, !entry.isPlayed) setPlayed(entry, !entry.isPlayed)
@@ -922,6 +928,11 @@ struct PodcastLibraryView: View {
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play", Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play") systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
} }
Button {
detailEntry = entry
} label: {
Label("Episode Details", systemImage: "info.circle")
}
Button { Button {
setPlayed(entry, !entry.isPlayed) setPlayed(entry, !entry.isPlayed)
} label: { } label: {
@@ -970,6 +981,7 @@ struct EpisodePickerView: View {
let claude: ClaudeService let claude: ClaudeService
@State private var entries: [PodcastEntry] = [] @State private var entries: [PodcastEntry] = []
@State private var detailEntry: PodcastEntry?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -1005,6 +1017,8 @@ struct EpisodePickerView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: { Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
@@ -1020,6 +1034,9 @@ struct EpisodePickerView: View {
Button("Done") { dismiss() } Button("Done") { dismiss() }
} }
} }
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude)
}
} }
.onAppear { entries = PodcastIndex.find(for: bookmark.url) } .onAppear { entries = PodcastIndex.find(for: bookmark.url) }
} }
@@ -1030,3 +1047,103 @@ struct EpisodePickerView: View {
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
} }
} }
// MARK: - Episode detail
/// Detail sheet for a single episode: reconnects the podcast to its source
/// bookmark and gathers per-episode actions (play, mark played, open source,
/// share, delete) in one place.
struct EpisodeDetailView: View {
let entry: PodcastEntry
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var library = PodcastLibrary.shared
private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl }
private var isPlayed: Bool {
library.entries.first { $0.articleUrl == entry.articleUrl }?.isPlayed ?? entry.isPlayed
}
private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) }
private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl }
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 20, weight: .semibold))
Label(domain, systemImage: "link")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 12) {
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
systemImage: "calendar")
if isPlayed {
Label("Played", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
Section {
Button {
if isCurrent {
vm.togglePlayPause()
} else {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? "", claude: claude)
}
} label: {
Label(isCurrent && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent && vm.isPlaying ? "pause.fill" : "play.fill")
}
Button {
library.setPlayed(entry.articleUrl, !isPlayed)
} label: {
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
systemImage: isPlayed ? "circle" : "checkmark.circle")
}
}
if let sourceURL {
Section {
Button {
openURL(sourceURL)
} label: {
Label("Open Source", systemImage: "safari")
}
ShareLink(item: sourceURL) {
Label("Share Episode", systemImage: "square.and.arrow.up")
}
}
}
Section {
Button(role: .destructive) {
library.remove(entry.articleUrl)
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
dismiss()
} label: {
Label("Delete Episode", systemImage: "trash")
}
}
}
.navigationTitle("Episode")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}