Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
1194 lines
46 KiB
Swift
1194 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,
|
||
sourceText: String? = nil,
|
||
sourceKind: 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,
|
||
title: articleTitle,
|
||
sourceText: sourceText,
|
||
sourceKind: sourceKind
|
||
)
|
||
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)
|
||
.paperSurface()
|
||
.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(Paper.alarm)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.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(Paper.secondary)
|
||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||
|
||
VStack(spacing: 10) {
|
||
ProgressView(value: progress)
|
||
.progressViewStyle(.linear)
|
||
.tint(Paper.accent)
|
||
Text(label)
|
||
.font(PaperType.meta)
|
||
.foregroundStyle(Paper.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func playerView(title: String) -> some View {
|
||
VStack(spacing: 28) {
|
||
RoundedRectangle(cornerRadius: 20)
|
||
.fill(Paper.raised)
|
||
.frame(width: 220, height: 220)
|
||
.overlay {
|
||
Image(systemName: "waveform.circle.fill")
|
||
.font(.system(size: 80))
|
||
.foregroundStyle(Paper.accent)
|
||
}
|
||
|
||
Text(title)
|
||
.font(PaperType.heading)
|
||
.multilineTextAlignment(.center)
|
||
.lineLimit(3)
|
||
|
||
VStack(spacing: 6) {
|
||
Slider(
|
||
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
|
||
in: 0...vm.duration
|
||
)
|
||
.tint(Paper.ink)
|
||
|
||
HStack {
|
||
Text(formatTime(vm.currentTime))
|
||
Spacer()
|
||
Text(formatTime(vm.duration))
|
||
}
|
||
.font(PaperType.micro)
|
||
.foregroundStyle(Paper.tertiary)
|
||
}
|
||
|
||
HStack(spacing: 36) {
|
||
Button { vm.skipBackward15() } label: {
|
||
Image(systemName: "gobackward.15")
|
||
.font(.system(size: 32))
|
||
.foregroundStyle(Paper.ink)
|
||
}
|
||
Button { vm.togglePlayPause() } label: {
|
||
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||
.font(.system(size: 72))
|
||
.foregroundStyle(Paper.ink)
|
||
}
|
||
Button { vm.skipForward15() } label: {
|
||
Image(systemName: "goforward.15")
|
||
.font(.system(size: 32))
|
||
.foregroundStyle(Paper.ink)
|
||
}
|
||
}
|
||
|
||
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(PaperType.meta)
|
||
.foregroundStyle(Paper.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 ? Paper.accent : .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(Paper.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func failedView(message: String) -> some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.font(.system(size: 44))
|
||
.foregroundStyle(Paper.alarm)
|
||
Text("Generation Failed")
|
||
.font(PaperType.heading)
|
||
Text(message)
|
||
.font(PaperType.meta)
|
||
.foregroundStyle(Paper.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(Paper.accent)
|
||
.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(Paper.accent)
|
||
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
|
||
.frame(width: 22)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
|
||
.font(PaperType.meta)
|
||
.foregroundStyle(Paper.ink)
|
||
.lineLimit(1)
|
||
Text(progressLabel)
|
||
.font(PaperType.micro)
|
||
.foregroundStyle(Paper.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(Paper.ink)
|
||
.frame(width: 36, height: 36)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
Button {
|
||
vm.stop()
|
||
} label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(Paper.secondary)
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 12)
|
||
.background(Paper.raised)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.overlay(alignment: .bottom) {
|
||
// Progress bar along bottom edge
|
||
GeometryReader { geo in
|
||
Rectangle()
|
||
.fill(Paper.accent.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 {
|
||
PaperEmptyState(
|
||
title: "No Podcasts",
|
||
systemImage: "headphones",
|
||
message: "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)
|
||
}
|
||
}
|
||
.paperSurface()
|
||
.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(PaperType.quote)
|
||
.lineLimit(2)
|
||
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
|
||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||
.font(PaperType.micro)
|
||
.foregroundStyle(Paper.secondary)
|
||
}
|
||
Spacer()
|
||
if entry.isPlayed {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(Paper.affirm)
|
||
.accessibilityLabel("Played")
|
||
}
|
||
Button {
|
||
play(entry)
|
||
} label: {
|
||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||
.font(.system(size: 36))
|
||
.foregroundStyle(Paper.accent)
|
||
.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(PaperType.quote)
|
||
.lineLimit(2)
|
||
if entry.articleUrl != bookmark.url {
|
||
Text(entry.articleUrl)
|
||
.font(PaperType.micro)
|
||
.foregroundStyle(Paper.tertiary)
|
||
.lineLimit(1)
|
||
}
|
||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||
.font(PaperType.micro)
|
||
.foregroundStyle(Paper.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(Paper.accent)
|
||
}
|
||
.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(PaperType.display)
|
||
Label(domain, systemImage: "link")
|
||
.font(.subheadline)
|
||
.foregroundStyle(Paper.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(Paper.affirm)
|
||
}
|
||
}
|
||
.font(.caption)
|
||
.foregroundStyle(Paper.secondary)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
if let summary {
|
||
Section("Summary") {
|
||
Text(summary)
|
||
.font(.subheadline)
|
||
.foregroundStyle(Paper.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|