Files
linkding-ios/Marks/Views/PodcastPlayerView.swift
Krishna Kumar abe35f47d6
Some checks failed
CI / build-and-deploy (push) Failing after 9s
Add mini-player, per-row podcast button, and resume-from-position
- MiniPlayerView: persistent bar above tab bar, shows title/progress,
  play/pause and close buttons, slides in when audio starts
- PodcastPlayerViewModel lifted to BookmarksViewModel so it persists
  across sheet dismissals — audio keeps playing in background
- Headphone button on every bookmark row (filled = cached, outline = not)
- start() is idempotent: re-tapping same article resumes without restart
- stop() saves playback position before teardown (PodcastProgress)
- setupPlayer restores saved position on next open
- BrowserView keeps its own local VM (stopOnDismiss: true)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:19:21 -05:00

427 lines
15 KiB
Swift

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 = ""
private var pollingTask: Task<Void, Never>?
private var timeObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast"
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
// Idempotent don't restart the same article if already playing
if currentArticleUrl == articleUrl && player != nil { return }
// Teardown previous episode (saves its position)
if player != nil { stop() }
currentArticleUrl = articleUrl
currentArticleTitle = articleTitle
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
if FileManager.default.fileExists(atPath: cached.path) {
setupPlayer(url: cached, title: "Podcast")
return
}
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)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return
}
if job.isFailed {
phase = .failed(job.error ?? "Generation failed")
return
}
try await Task.sleep(for: .seconds(3))
}
} catch is CancellationError {
} catch {
phase = .failed(error.localizedDescription)
}
}
}
func stop() {
if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
}
pollingTask?.cancel()
player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) }
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)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func togglePlayPause() {
guard let player else { return }
if isPlaying { player.pause() } else { player.play() }
isPlaying.toggle()
updateNowPlaying()
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
currentTime = time
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 {
p.seek(to: CMTime(seconds: saved, preferredTimescale: 600))
currentTime = saved
}
updateNowPlaying()
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
self?.currentTime = t.seconds
self?.updateNowPlaying()
}
setupRemoteCommands()
phase = .ready(title: title)
updateNowPlaying()
p.play()
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
}
}
private func updateNowPlaying() {
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentPodcastTitle
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private 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.player != nil {
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)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
}
}
}
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)
}
}
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)
.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()
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"
}
}