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 }
}
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.
static func setPlayed(articleUrl: String, _ played: Bool = true) {
var entries = all()

View File

@@ -358,6 +358,13 @@ struct PodcastPlayerView: View {
var stopOnDismiss: Bool = false
@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 {
NavigationStack {
@@ -385,6 +392,10 @@ struct PodcastPlayerView: View {
}
.onAppear {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onChange(of: vm.currentArticleUrl) { _, _ in
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onDisappear {
if stopOnDismiss { vm.stop() }
@@ -495,6 +506,19 @@ struct PodcastPlayerView: View {
.glassEffect(in: Capsule())
}
HStack(spacing: 28) {
Button {
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))
.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")
@@ -504,6 +528,7 @@ struct PodcastPlayerView: View {
}
}
}
}
private func failedView(message: String) -> some View {
VStack(spacing: 16) {
@@ -713,6 +738,12 @@ struct PodcastLibraryView: View {
.foregroundStyle(.secondary)
}
Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.accessibilityLabel("Played")
}
Button {
play(entry)
} label: {
@@ -743,6 +774,29 @@ struct PodcastLibraryView: View {
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) {