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 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user