Files
linkding-ios/Marks/Views/PodcastPlayerView.swift
Krishna Kumar af3112530e
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s
feat(podcasts): played/unplayed queue, background generation, share-sheet audio
Turns the Podcasts tab into a proper podcast-app experience across three
features that compose on a shared background-generation service.

Background generation (#2)
- New PodcastGenerationManager runs generate→poll→download off the player,
  so producing a new episode never stops current playback.
- Headphone taps never interrupt: idle → generate-and-play in the foreground
  (unchanged); already playing → generate in the background, episode lands in
  the library. A "Generating…" banner surfaces active jobs.

Played/unplayed + queue (#1)
- PodcastEntry gains playedAt (old index.json decodes as unplayed).
- Podcasts tab splits into Up Next / Played with a Play All queue that
  auto-advances; finishing marks played; swipe to toggle played state.

Share-extension audio (#3)
- "Create podcast" toggle on the save card, plus configurable Auto-Podcast
  Tags in Settings. The extension enqueues a request into the App Group; the
  main app drains it on launch/foreground and generates in the background.

Closes #1
Closes #2
Closes #3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:37:42 -05:00

842 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AVFoundation
import MediaPlayer
// MARK: - Playback position persistence
enum PodcastProgress {
private static let key = "podcastProgress"
static func get(url: String) -> Double {
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0
}
static func set(url: String, time: Double, duration: Double) {
var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
let fraction = time / max(duration, 1)
if fraction > 0.97 || time < 10 {
store.removeValue(forKey: url)
} else {
store[url] = time
}
UserDefaults.standard.set(store, forKey: key)
}
}
// MARK: - ViewModel
@Observable
@MainActor
final class PodcastPlayerViewModel {
enum Phase {
case generating(progress: Double, label: String)
case downloading
case ready(title: String)
case failed(String)
}
var phase: Phase = .generating(progress: 0, label: "Starting…")
var player: AVPlayer?
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 1
var currentArticleUrl: String = ""
var currentArticleTitle: String = ""
var playbackSpeed: Float = 1.0
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
var isGenerating: Bool {
switch phase {
case .generating, .downloading: return true
default: return false
}
}
/// Remaining episodes to auto-play after the current one finishes.
private(set) var queue: [PodcastEntry] = []
/// Retained so queued episodes can be started from the end-of-item observer.
private var lastClaude: ClaudeService?
private var pollingTask: Task<Void, Never>?
private var timeObserver: Any?
private var endObserver: Any?
private var interruptionObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast"
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
// Idempotent don't restart if already generating or playing this article
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
// Teardown previous episode (saves its position)
if player != nil { stop() }
lastClaude = claude
currentArticleUrl = articleUrl
currentArticleTitle = articleTitle
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
if FileManager.default.fileExists(atPath: cached.path) {
Analytics.track("podcast.started", ["url": articleUrl, "cached": true])
setupPlayer(url: cached, title: "Podcast")
return
}
Analytics.track("podcast.started", ["url": articleUrl, "cached": false])
phase = .generating(progress: 0, label: "Starting…")
pollingTask = Task {
do {
let jobId = try await claude.generatePodcast(url: articleUrl)
while !Task.isCancelled {
let job = try await claude.podcastStatus(jobId: jobId)
phase = .generating(
progress: Double(job.progress) / 100,
label: Self.statusLabel(job.status)
)
if job.isDone {
phase = .downloading
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return
}
if job.isFailed {
let errMsg = job.error ?? "Generation failed"
Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg])
phase = .failed(errMsg)
pollingTask = nil
return
}
try await Task.sleep(for: .seconds(3))
}
} catch is CancellationError {
} catch {
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
phase = .failed(error.localizedDescription)
pollingTask = nil
}
}
}
/// Play `entries` in order, auto-advancing through the queue as each finishes.
func playQueue(_ entries: [PodcastEntry], claude: ClaudeService) {
guard let first = entries.first else { return }
// `start` calls `stop`, which clears the queue set it afterwards.
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
queue = Array(entries.dropFirst())
}
func stop() {
queue = []
if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
}
pollingTask?.cancel()
player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) }
if let obs = endObserver { NotificationCenter.default.removeObserver(obs) }
endObserver = nil
if let obs = interruptionObserver { NotificationCenter.default.removeObserver(obs) }
interruptionObserver = nil
player = nil
isPlaying = false
currentTime = 0
duration = 1
currentArticleUrl = ""
currentArticleTitle = ""
currentPodcastTitle = "Marks Podcast"
phase = .generating(progress: 0, label: "Starting…")
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
let c = MPRemoteCommandCenter.shared()
c.playCommand.removeTarget(nil)
c.pauseCommand.removeTarget(nil)
c.changePlaybackPositionCommand.removeTarget(nil)
c.skipBackwardCommand.removeTarget(nil)
c.skipForwardCommand.removeTarget(nil)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func togglePlayPause() {
guard let player else { return }
if isPlaying {
player.pause()
} else {
player.rate = playbackSpeed
}
isPlaying.toggle()
updateNowPlaying()
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
currentTime = time
updateNowPlaying()
}
func skipForward15() {
seek(to: min(currentTime + 15, duration))
}
func skipBackward15() {
seek(to: max(currentTime - 15, 0))
}
func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed
if isPlaying { player?.rate = speed }
updateNowPlaying()
}
private func setupPlayer(url: URL, title: String) {
currentPodcastTitle = title
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
options: [.allowBluetoothA2DP])
try? AVAudioSession.sharedInstance().setActive(true)
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
player = p
Task {
if let d = try? await item.asset.load(.duration), d.isNumeric {
duration = max(d.seconds, 1)
// Restore saved position
let saved = PodcastProgress.get(url: currentArticleUrl)
if saved > 10 {
await p.seek(to: CMTime(seconds: saved, preferredTimescale: 600))
currentTime = saved
}
updateNowPlaying()
}
}
// On finish: mark the episode played, then auto-advance to the next
// queued episode, or reset to the start like a standard media player.
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
guard let self else { return }
let finished = self.currentArticleUrl
PodcastIndex.setPlayed(articleUrl: finished, true)
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
if !self.queue.isEmpty {
let next = self.queue.removeFirst()
let remaining = self.queue
let claude = self.lastClaude ?? ClaudeService()
self.start(articleUrl: next.articleUrl,
articleTitle: next.title ?? "", claude: claude)
self.queue = remaining // `start` cleared it via `stop`
} else {
self.isPlaying = false
self.currentTime = 0
self.player?.seek(to: .zero)
self.updateNowPlaying()
}
}
// Pause on phone calls / Siri; resume when interruption ends
interruptionObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
) { [weak self] notification in
guard let self,
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
if type == .began {
self.player?.pause()
self.isPlaying = false
self.updateNowPlaying()
} else if type == .ended {
let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
self.player?.rate = self.playbackSpeed
self.isPlaying = true
self.updateNowPlaying()
}
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
guard let self else { return }
let prev = self.currentTime
self.currentTime = t.seconds
self.updateNowPlaying()
// Track completion when crossing 97%
if self.duration > 1 && prev / self.duration < 0.97 && t.seconds / self.duration >= 0.97 {
Analytics.track("podcast.completed", ["url": self.currentArticleUrl, "durationSec": Int(self.duration)])
}
}
setupRemoteCommands()
phase = .ready(title: title)
updateNowPlaying()
p.rate = playbackSpeed
isPlaying = true
}
private func setupRemoteCommands() {
let c = MPRemoteCommandCenter.shared()
c.playCommand.isEnabled = true
c.playCommand.addTarget { [weak self] _ in
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
}
c.pauseCommand.isEnabled = true
c.pauseCommand.addTarget { [weak self] _ in
self?.player?.pause(); self?.isPlaying = false; self?.updateNowPlaying(); return .success
}
c.changePlaybackPositionCommand.isEnabled = true
c.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.player?.seek(to: CMTime(seconds: e.positionTime, preferredTimescale: 600))
self?.currentTime = e.positionTime
return .success
}
c.skipBackwardCommand.isEnabled = true
c.skipBackwardCommand.preferredIntervals = [15]
c.skipBackwardCommand.addTarget { [weak self] _ in
self?.skipBackward15(); return .success
}
c.skipForwardCommand.isEnabled = true
c.skipForwardCommand.preferredIntervals = [15]
c.skipForwardCommand.addTarget { [weak self] _ in
self?.skipForward15(); return .success
}
}
private func updateNowPlaying() {
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentPodcastTitle
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? Double(playbackSpeed) : 0.0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
if let artwork = Self.nowPlayingArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private static let nowPlayingArtwork: MPMediaItemArtwork? = {
let size = CGSize(width: 300, height: 300)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
UIColor.systemBlue.withAlphaComponent(0.15).setFill()
ctx.fill(CGRect(origin: .zero, size: size))
let config = UIImage.SymbolConfiguration(pointSize: 140, weight: .regular)
if let symbol = UIImage(systemName: "waveform.circle.fill", withConfiguration: config) {
let tinted = symbol.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
let origin = CGPoint(x: (size.width - tinted.size.width) / 2,
y: (size.height - tinted.size.height) / 2)
tinted.draw(at: origin)
}
}
return MPMediaItemArtwork(boundsSize: size) { _ in image }
}()
static func statusLabel(_ status: String) -> String {
switch status {
case "queued": return "Queued…"
case "fetching": return "Fetching article…"
case "scripting": return "Writing dialogue…"
case "recording": return "Recording voices…"
case "mixing": return "Mixing audio…"
default: return "Generating…"
}
}
}
// MARK: - Full player sheet
struct PodcastPlayerView: View {
let vm: PodcastPlayerViewModel
let articleUrl: String
let articleTitle: String
let claude: ClaudeService
var stopOnDismiss: Bool = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack {
Spacer()
phaseContent
Spacer()
}
.padding(.horizontal, 32)
.navigationTitle("Podcast")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
if !vm.currentArticleUrl.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle")
.foregroundStyle(.red)
}
}
}
}
}
.onAppear {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
}
.onDisappear {
if stopOnDismiss { vm.stop() }
}
}
@ViewBuilder
private var phaseContent: some View {
switch vm.phase {
case .generating(let progress, let label):
generatingView(progress: progress, label: label)
case .downloading:
generatingView(progress: 1.0, label: "Preparing audio…")
case .ready(let title):
playerView(title: title)
case .failed(let message):
failedView(message: message)
}
}
private func generatingView(progress: Double, label: String) -> some View {
VStack(spacing: 28) {
Image(systemName: "waveform")
.font(.system(size: 52))
.foregroundStyle(.secondary)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(spacing: 10) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
Text(label)
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
}
}
private func playerView(title: String) -> some View {
VStack(spacing: 28) {
RoundedRectangle(cornerRadius: 20)
.fill(Color(.systemGray6))
.frame(width: 220, height: 220)
.overlay {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.blue)
}
Text(title)
.font(.system(size: 18, weight: .semibold))
.multilineTextAlignment(.center)
.lineLimit(3)
VStack(spacing: 6) {
Slider(
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
in: 0...vm.duration
)
.tint(.primary)
HStack {
Text(formatTime(vm.currentTime))
Spacer()
Text(formatTime(vm.duration))
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.tertiary)
}
HStack(spacing: 36) {
Button { vm.skipBackward15() } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
}
Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
}
Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button {
vm.setPlaybackSpeed(speed)
} label: {
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
if speed == vm.playbackSpeed {
Label(label, systemImage: "checkmark")
} else {
Text(label)
}
}
}
} label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
}
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)
}
}
}
}
private func failedView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 44))
.foregroundStyle(.red)
Text("Generation Failed")
.font(.headline)
Text(message)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
.font(.system(size: 15, weight: .semibold))
}
.buttonStyle(.glassProminent)
.tint(.blue)
.buttonBorderShape(.capsule)
.padding(.top, 4)
}
}
private func formatTime(_ s: Double) -> String {
let total = Int(max(s, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
}
// MARK: - Mini player
struct MiniPlayerView: View {
let vm: PodcastPlayerViewModel
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 14) {
Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
Text(progressLabel)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
if vm.isGenerating {
ProgressView()
.frame(width: 36, height: 36)
} else {
Button {
vm.togglePlayPause()
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
Button {
vm.stop()
} label: {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .bottom) {
// Progress bar along bottom edge
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.6))
.frame(width: geo.size.width * progressFraction, height: 3)
}
.frame(height: 3)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.shadow(color: .black.opacity(0.12), radius: 12, x: 0, y: 4)
}
.buttonStyle(.plain)
.padding(.horizontal, 12)
}
private var progressFraction: Double {
guard vm.duration > 1 else { return 0 }
return min(vm.currentTime / vm.duration, 1)
}
private var progressLabel: String {
guard vm.duration > 1 else {
if case .generating(_, let label) = vm.phase { return label }
return "Generating…"
}
let remaining = vm.duration - vm.currentTime
let mins = Int(remaining) / 60
return mins > 0 ? "\(mins) min left" : "Almost done"
}
}
// MARK: - Podcast library
/// The Podcasts tab: lists every podcast generated in the app (from
/// `PodcastIndex`) and plays them on demand through the shared player, with a
/// now-playing bar that opens the full player.
struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@State private var showFullPlayer = false
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate from bookmarks will appear here.")
)
} else {
List {
if !unplayed.isEmpty {
Section("Up Next") {
ForEach(unplayed) { row(for: $0) }
}
}
if !played.isEmpty {
Section("Played") {
ForEach(played) { row(for: $0) }
}
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Podcasts")
.toolbar {
if !unplayed.isEmpty {
ToolbarItem(placement: .topBarLeading) {
Button {
vm.playQueue(unplayed, claude: claude)
} label: {
Label("Play All", systemImage: "play.fill")
}
}
}
}
.overlay(alignment: .bottom) {
if !vm.currentArticleUrl.isEmpty {
MiniPlayerView(vm: vm) { showFullPlayer = true }
.padding(.horizontal)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.spring(duration: 0.3), value: vm.currentArticleUrl.isEmpty)
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: vm,
articleUrl: vm.currentArticleUrl,
articleTitle: vm.currentArticleTitle,
claude: claude,
stopOnDismiss: false
)
}
}
.onAppear { entries = PodcastIndex.all() }
// When the current episode changes (finished, advanced, or stopped),
// re-read the index so played items move into the Played section.
.onChange(of: vm.currentArticleUrl) { _, _ in entries = PodcastIndex.all() }
}
private var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
private var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
@ViewBuilder
private func row(for entry: PodcastEntry) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
play(entry)
} label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.swipeActions(edge: .leading) {
Button {
setPlayed(entry, !entry.isPlayed)
} label: {
if entry.isPlayed {
Label("Unplayed", systemImage: "circle")
} else {
Label("Played", systemImage: "checkmark.circle")
}
}
.tint(entry.isPlayed ? .gray : .green)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
PodcastIndex.setPlayed(articleUrl: entry.articleUrl, played)
if let i = entries.firstIndex(where: { $0.articleUrl == entry.articleUrl }) {
entries[i].playedAt = played ? Date() : nil
}
}
private func isCurrent(_ entry: PodcastEntry) -> Bool {
vm.currentArticleUrl == entry.articleUrl
}
private func play(_ entry: PodcastEntry) {
if isCurrent(entry) {
vm.togglePlayPause()
} else {
vm.start(articleUrl: entry.articleUrl, articleTitle: entry.title ?? "", claude: claude)
}
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
}
}
// MARK: - Episode picker (multiple episodes per bookmark)
struct EpisodePickerView: View {
let bookmark: Bookmark
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(entries) { entry in
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
if entry.articleUrl != bookmark.url {
Text(entry.articleUrl)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? bookmark.displayTitle,
claude: claude)
dismiss()
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Episodes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
}
}