Files
linkding-ios/Marks/Views/PodcastPlayerView.swift
Krishna Kumar 64a944ab10
All checks were successful
CI / build-and-deploy (push) Successful in 14s
style(ui): adopt Liquid Glass on opaque controls
Swap hardcoded systemGray/Color.blue pill and button fills for the iOS 26
Liquid Glass APIs so controls read native under the new design system:
- BookmarkRow tag chips: glassEffect in a GlassEffectContainer
- Podcast speed button: glassEffect capsule
- Podcast retry + Onboarding Connect: glassProminent button style

Onboarding text-field fills left as systemGray6 (standard adaptive field
fill; glass behind editable text hurts legibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:10:23 -05:00

743 lines
28 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
}
}
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() }
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
}
}
}
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) }
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()
}
}
// Reset to start when playback finishes, like standard media players
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
guard let self else { return }
self.isPlaying = false
self.currentTime = 0
self.player?.seek(to: .zero)
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
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 }
}()
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.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
struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate will appear here.")
)
} else {
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)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? "",
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")
}
}
}
.onDelete { indexSet in
indexSet.forEach { deleteEntry(entries[$0]) }
}
}
.listStyle(.plain)
}
}
.navigationTitle("Podcasts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.onAppear { entries = PodcastIndex.all() }
}
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() }
}
}