Resolves the two deviations from the episode detail sheet: - AI summary now shows in the detail sheet. Added a URL-keyed AISummaryStore that BookmarksViewModel populates as it enriches/loads bookmarks, so the decoupled Podcasts tab can surface a summary from just the episode URL — without recoupling to BookmarksViewModel. - "Open Bookmark" opens the in-app BrowserView as a nested sheet (generator threaded through PodcastLibraryView / EpisodePickerView), alongside the existing "Open in Safari" and Share. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1180 lines
46 KiB
Swift
1180 lines
46 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 = ""
|
||
var playbackSpeed: Float = PodcastPlayerViewModel.savedSpeed
|
||
|
||
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
|
||
|
||
/// Sleep-timer selection. `.off` when disabled.
|
||
enum SleepTimer: Hashable { case off, minutes(Int), endOfEpisode }
|
||
var sleepTimer: SleepTimer = .off
|
||
|
||
private static let speedKey = "podcastPlaybackSpeed"
|
||
private static let sessionKey = "podcastSession"
|
||
static var savedSpeed: Float {
|
||
let v = UserDefaults.standard.double(forKey: speedKey)
|
||
return v > 0 ? Float(v) : 1.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 sleepTask: 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())
|
||
persistSession()
|
||
}
|
||
|
||
func stop() {
|
||
queue = []
|
||
clearSession()
|
||
sleepTask?.cancel(); sleepTask = nil
|
||
sleepTimer = .off
|
||
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 {
|
||
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
|
||
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
|
||
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
|
||
if isPlaying { player?.rate = speed }
|
||
updateNowPlaying()
|
||
}
|
||
|
||
// MARK: - Sleep timer
|
||
|
||
func setSleepTimer(_ timer: SleepTimer) {
|
||
sleepTimer = timer
|
||
sleepTask?.cancel(); sleepTask = nil
|
||
guard case .minutes(let m) = timer else { return } // .endOfEpisode handled at finish
|
||
sleepTask = Task { [weak self] in
|
||
try? await Task.sleep(for: .seconds(Double(m) * 60))
|
||
guard !Task.isCancelled else { return }
|
||
self?.fireSleepTimer()
|
||
}
|
||
}
|
||
|
||
private func fireSleepTimer() {
|
||
player?.pause()
|
||
isPlaying = false
|
||
sleepTimer = .off
|
||
sleepTask = nil
|
||
updateNowPlaying()
|
||
}
|
||
|
||
// MARK: - Session persistence (survives app relaunch)
|
||
|
||
private struct SavedSession: Codable {
|
||
let currentUrl: String
|
||
let currentTitle: String
|
||
let podcastTitle: String
|
||
let queue: [PodcastEntry]
|
||
}
|
||
|
||
/// Persist the current episode + queue so relaunch can restore a paused session.
|
||
func persistSession() {
|
||
guard !currentArticleUrl.isEmpty else { clearSession(); return }
|
||
let session = SavedSession(currentUrl: currentArticleUrl,
|
||
currentTitle: currentArticleTitle,
|
||
podcastTitle: currentPodcastTitle,
|
||
queue: queue)
|
||
if let data = try? JSONEncoder().encode(session) {
|
||
UserDefaults.standard.set(data, forKey: Self.sessionKey)
|
||
}
|
||
}
|
||
|
||
private func clearSession() {
|
||
UserDefaults.standard.removeObject(forKey: Self.sessionKey)
|
||
}
|
||
|
||
/// Save the current playback position + session (call when backgrounding).
|
||
func saveProgress() {
|
||
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||
}
|
||
persistSession()
|
||
}
|
||
|
||
/// Restore a previously-playing episode into a **paused** player on launch,
|
||
/// with its queue intact. No-op if nothing is saved or the audio is gone.
|
||
func restoreSession(claude: ClaudeService) {
|
||
guard currentArticleUrl.isEmpty, player == nil else { return }
|
||
guard let data = UserDefaults.standard.data(forKey: Self.sessionKey),
|
||
let session = try? JSONDecoder().decode(SavedSession.self, from: data) else { return }
|
||
let cached = ClaudeService.cachedPodcastURL(for: session.currentUrl)
|
||
guard FileManager.default.fileExists(atPath: cached.path) else { clearSession(); return }
|
||
lastClaude = claude
|
||
currentArticleUrl = session.currentUrl
|
||
currentArticleTitle = session.currentTitle
|
||
queue = session.queue
|
||
setupPlayer(url: cached, title: session.podcastTitle, autoPlay: false)
|
||
}
|
||
|
||
private func setupPlayer(url: URL, title: String, autoPlay: Bool = true) {
|
||
currentPodcastTitle = title
|
||
// A freshly generated episode may have just landed in the index.
|
||
PodcastLibrary.shared.reload()
|
||
|
||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
||
options: [.allowBluetoothA2DP])
|
||
// Only claim the audio session when actually playing — a restored, paused
|
||
// session on launch must not interrupt the user's other audio.
|
||
if autoPlay { 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)
|
||
PodcastLibrary.shared.reload()
|
||
|
||
// Sleep timer set to "end of episode" stops here — no auto-advance.
|
||
let stopForSleep = self.sleepTimer == .endOfEpisode
|
||
if stopForSleep { self.sleepTimer = .off }
|
||
|
||
if !stopForSleep, !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`
|
||
self.persistSession()
|
||
} else {
|
||
self.isPlaying = false
|
||
self.currentTime = 0
|
||
self.player?.seek(to: .zero)
|
||
self.updateNowPlaying()
|
||
self.persistSession()
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
if autoPlay {
|
||
p.rate = playbackSpeed
|
||
isPlaying = true
|
||
} else {
|
||
isPlaying = false
|
||
}
|
||
persistSession()
|
||
}
|
||
|
||
private func setupRemoteCommands() {
|
||
let c = MPRemoteCommandCenter.shared()
|
||
c.playCommand.isEnabled = true
|
||
c.playCommand.addTarget { [weak self] _ in
|
||
try? AVAudioSession.sharedInstance().setActive(true)
|
||
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
|
||
@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
|
||
}
|
||
|
||
private var sleepActive: Bool { vm.sleepTimer != .off }
|
||
private var sleepLabel: String {
|
||
switch vm.sleepTimer {
|
||
case .off: return "Sleep"
|
||
case .minutes(let m): return "\(m) min"
|
||
case .endOfEpisode: return "End"
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func sleepButton(_ title: String, _ option: PodcastPlayerViewModel.SleepTimer) -> some View {
|
||
Button { vm.setSleepTimer(option) } label: {
|
||
if vm.sleepTimer == option {
|
||
Label(title, systemImage: "checkmark")
|
||
} else {
|
||
Text(title)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
|
||
}
|
||
.onChange(of: vm.currentArticleUrl) { _, _ in
|
||
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
|
||
}
|
||
.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)
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 12) {
|
||
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())
|
||
}
|
||
|
||
Menu {
|
||
sleepButton("Off", .off)
|
||
sleepButton("15 Minutes", .minutes(15))
|
||
sleepButton("30 Minutes", .minutes(30))
|
||
sleepButton("45 Minutes", .minutes(45))
|
||
sleepButton("End of Episode", .endOfEpisode)
|
||
} label: {
|
||
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(sleepActive ? Color.blue : .secondary)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 7)
|
||
.glassEffect(in: Capsule())
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 28) {
|
||
Button {
|
||
isPlayed.toggle()
|
||
PodcastLibrary.shared.setPlayed(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")
|
||
.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
|
||
let podcastGenerator: PodcastGenerationManager
|
||
|
||
@State private var library = PodcastLibrary.shared
|
||
@State private var showFullPlayer = false
|
||
@State private var detailEntry: PodcastEntry?
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Group {
|
||
if library.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
|
||
)
|
||
}
|
||
.sheet(item: $detailEntry) { entry in
|
||
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
|
||
}
|
||
}
|
||
.onAppear { library.reload() }
|
||
// 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 library.reload() }
|
||
}
|
||
|
||
private var unplayed: [PodcastEntry] { library.unplayed }
|
||
private var played: [PodcastEntry] { library.played }
|
||
|
||
@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()
|
||
if entry.isPlayed {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(.green)
|
||
.accessibilityLabel("Played")
|
||
}
|
||
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)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { detailEntry = entry }
|
||
.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")
|
||
}
|
||
}
|
||
.contextMenu {
|
||
Button {
|
||
play(entry)
|
||
} label: {
|
||
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
|
||
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
|
||
}
|
||
Button {
|
||
detailEntry = entry
|
||
} label: {
|
||
Label("Episode Details", systemImage: "info.circle")
|
||
}
|
||
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) {
|
||
library.setPlayed(entry.articleUrl, played)
|
||
}
|
||
|
||
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) {
|
||
library.remove(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
|
||
let podcastGenerator: PodcastGenerationManager
|
||
|
||
@State private var entries: [PodcastEntry] = []
|
||
@State private var detailEntry: 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)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { detailEntry = entry }
|
||
.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() }
|
||
}
|
||
}
|
||
.sheet(item: $detailEntry) { entry in
|
||
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
|
||
}
|
||
}
|
||
.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() }
|
||
}
|
||
}
|
||
|
||
// MARK: - Episode detail
|
||
|
||
/// Detail sheet for a single episode: reconnects the podcast to its source
|
||
/// bookmark and gathers per-episode actions (play, mark played, open source,
|
||
/// share, delete) in one place.
|
||
struct EpisodeDetailView: View {
|
||
let entry: PodcastEntry
|
||
let vm: PodcastPlayerViewModel
|
||
let claude: ClaudeService
|
||
let podcastGenerator: PodcastGenerationManager
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.openURL) private var openURL
|
||
@State private var library = PodcastLibrary.shared
|
||
@State private var showBrowser = false
|
||
|
||
private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl }
|
||
private var isPlayed: Bool {
|
||
library.entries.first { $0.articleUrl == entry.articleUrl }?.isPlayed ?? entry.isPlayed
|
||
}
|
||
private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) }
|
||
private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl }
|
||
private var summary: String? {
|
||
AISummaryStore.summary(for: entry.parentBookmarkUrl ?? entry.articleUrl)
|
||
?? AISummaryStore.summary(for: entry.articleUrl)
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(entry.title ?? entry.articleUrl)
|
||
.font(.system(size: 20, weight: .semibold))
|
||
Label(domain, systemImage: "link")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
HStack(spacing: 12) {
|
||
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
|
||
systemImage: "calendar")
|
||
if isPlayed {
|
||
Label("Played", systemImage: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
}
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
if let summary {
|
||
Section("Summary") {
|
||
Text(summary)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Button {
|
||
if isCurrent {
|
||
vm.togglePlayPause()
|
||
} else {
|
||
vm.start(articleUrl: entry.articleUrl,
|
||
articleTitle: entry.title ?? "", claude: claude)
|
||
}
|
||
} label: {
|
||
Label(isCurrent && vm.isPlaying ? "Pause" : "Play",
|
||
systemImage: isCurrent && vm.isPlaying ? "pause.fill" : "play.fill")
|
||
}
|
||
Button {
|
||
library.setPlayed(entry.articleUrl, !isPlayed)
|
||
} label: {
|
||
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
|
||
systemImage: isPlayed ? "circle" : "checkmark.circle")
|
||
}
|
||
}
|
||
|
||
if let sourceURL {
|
||
Section {
|
||
Button {
|
||
showBrowser = true
|
||
} label: {
|
||
Label("Open Bookmark", systemImage: "book")
|
||
}
|
||
Button {
|
||
openURL(sourceURL)
|
||
} label: {
|
||
Label("Open in Safari", systemImage: "safari")
|
||
}
|
||
ShareLink(item: sourceURL) {
|
||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||
}
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Button(role: .destructive) {
|
||
library.remove(entry.articleUrl)
|
||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||
dismiss()
|
||
} label: {
|
||
Label("Delete Episode", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Episode")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { dismiss() }
|
||
}
|
||
}
|
||
.sheet(isPresented: $showBrowser) {
|
||
if let sourceURL {
|
||
BrowserView(url: sourceURL,
|
||
title: entry.title ?? domain,
|
||
claude: claude,
|
||
podcastPlayer: vm,
|
||
podcastGenerator: podcastGenerator)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|