feat(podcasts): #8 episode detail sheet (source, played state, actions)
- New EpisodeDetailView: title, source domain, created date, played badge, and per-episode actions — Play/Pause, Mark Played/Unplayed, Open Source, Share, Delete — all routed through the PodcastLibrary store so the badge stays in sync. - Reachable by tapping a row (or "Episode Details" in the context menu) in the Podcasts tab, and by tapping a row in the episode picker. Note: AI summary is intentionally omitted for now — it's keyed by bookmark id (not URL), so surfacing it would recouple the deliberately-decoupled Podcasts tab to BookmarksViewModel. Open Source uses Safari rather than the in-app browser to avoid sheet-stacking. Both are noted as follow-ups on issue #8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -800,6 +800,7 @@ struct PodcastLibraryView: View {
|
||||
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var showFullPlayer = false
|
||||
@State private var detailEntry: PodcastEntry?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -856,6 +857,9 @@ struct PodcastLibraryView: View {
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.sheet(item: $detailEntry) { entry in
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude)
|
||||
}
|
||||
}
|
||||
.onAppear { library.reload() }
|
||||
// When the current episode changes (finished, advanced, or stopped),
|
||||
@@ -896,6 +900,8 @@ struct PodcastLibraryView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { detailEntry = entry }
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
setPlayed(entry, !entry.isPlayed)
|
||||
@@ -922,6 +928,11 @@ struct PodcastLibraryView: View {
|
||||
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
|
||||
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
|
||||
}
|
||||
Button {
|
||||
detailEntry = entry
|
||||
} label: {
|
||||
Label("Episode Details", systemImage: "info.circle")
|
||||
}
|
||||
Button {
|
||||
setPlayed(entry, !entry.isPlayed)
|
||||
} label: {
|
||||
@@ -970,6 +981,7 @@ struct EpisodePickerView: View {
|
||||
let claude: ClaudeService
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@State private var detailEntry: PodcastEntry?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -1005,6 +1017,8 @@ struct EpisodePickerView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { detailEntry = entry }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) { deleteEntry(entry) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
@@ -1020,6 +1034,9 @@ struct EpisodePickerView: View {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $detailEntry) { entry in
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude)
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
|
||||
}
|
||||
@@ -1030,3 +1047,103 @@ struct EpisodePickerView: View {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user