feat(podcasts): surface mark-played in context menu, full player, and row badge
All checks were successful
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (pull_request) Successful in 18s

- Add a long-press context menu on each library row (Play/Pause,
  Mark as Played/Unplayed, Delete).
- Add a Mark as Played/Unplayed control next to Share Episode in the full
  player, tracking the loaded episode across queue advances.
- Show a green checkmark on played rows, on top of the existing Played
  section + dimmed title, so played state reads at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-07-01 12:01:57 -05:00
parent af3112530e
commit 5e69d99996
2 changed files with 62 additions and 4 deletions

View File

@@ -54,6 +54,10 @@ enum PodcastIndex {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl } all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
} }
static func isPlayed(articleUrl: String) -> Bool {
all().first { $0.articleUrl == articleUrl }?.isPlayed ?? false
}
/// Mark an episode played (default) or back to unplayed. No-op if unknown. /// Mark an episode played (default) or back to unplayed. No-op if unknown.
static func setPlayed(articleUrl: String, _ played: Bool = true) { static func setPlayed(articleUrl: String, _ played: Bool = true) {
var entries = all() var entries = all()

View File

@@ -358,6 +358,13 @@ struct PodcastPlayerView: View {
var stopOnDismiss: Bool = false var stopOnDismiss: Bool = false
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var isPlayed = false
/// The episode currently loaded in the player (follows queue advances),
/// falling back to the URL this sheet was opened with.
private var activeUrl: String {
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -385,6 +392,10 @@ struct PodcastPlayerView: View {
} }
.onAppear { .onAppear {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onChange(of: vm.currentArticleUrl) { _, _ in
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
} }
.onDisappear { .onDisappear {
if stopOnDismiss { vm.stop() } if stopOnDismiss { vm.stop() }
@@ -495,11 +506,25 @@ struct PodcastPlayerView: View {
.glassEffect(in: Capsule()) .glassEffect(in: Capsule())
} }
if let url = URL(string: articleUrl) { HStack(spacing: 28) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { Button {
Label("Share Episode", systemImage: "square.and.arrow.up") isPlayed.toggle()
PodcastIndex.setPlayed(articleUrl: activeUrl, isPlayed)
} label: {
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary) .foregroundStyle(isPlayed ? Color.green : .secondary)
}
.buttonStyle(.plain)
.contentTransition(.symbolEffect(.replace))
if let url = URL(string: articleUrl) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
}
} }
} }
} }
@@ -713,6 +738,12 @@ struct PodcastLibraryView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.accessibilityLabel("Played")
}
Button { Button {
play(entry) play(entry)
} label: { } label: {
@@ -743,6 +774,29 @@ struct PodcastLibraryView: View {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
} }
.contextMenu {
Button {
play(entry)
} label: {
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
}
Button {
setPlayed(entry, !entry.isPlayed)
} label: {
if entry.isPlayed {
Label("Mark as Unplayed", systemImage: "circle")
} else {
Label("Mark as Played", systemImage: "checkmark.circle")
}
}
Divider()
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
} }
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) { private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {