556 lines
20 KiB
Swift
556 lines
20 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 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 currentPodcastTitle: String = "Marks Podcast"
|
|
|
|
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
|
|
// 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)
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
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.play() }
|
|
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))
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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.play()
|
|
isPlaying = true
|
|
}
|
|
|
|
private func setupRemoteCommands() {
|
|
let c = MPRemoteCommandCenter.shared()
|
|
c.playCommand.isEnabled = true
|
|
c.playCommand.addTarget { [weak self] _ in
|
|
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
|
|
}
|
|
c.pauseCommand.isEnabled = true
|
|
c.pauseCommand.addTarget { [weak self] _ in
|
|
self?.player?.pause(); self?.isPlaying = false; self?.updateNowPlaying(); return .success
|
|
}
|
|
c.changePlaybackPositionCommand.isEnabled = true
|
|
c.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
|
self?.player?.seek(to: CMTime(seconds: e.positionTime, preferredTimescale: 600))
|
|
self?.currentTime = e.positionTime
|
|
return .success
|
|
}
|
|
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 ? 1.0 : 0.0
|
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
|
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
|
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
}
|
|
|
|
private static func statusLabel(_ status: String) -> String {
|
|
switch status {
|
|
case "queued": return "Queued…"
|
|
case "fetching": return "Fetching article…"
|
|
case "scripting": return "Writing dialogue…"
|
|
case "recording": return "Recording voices…"
|
|
case "mixing": return "Mixing audio…"
|
|
default: return "Generating…"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Full player sheet
|
|
|
|
struct PodcastPlayerView: View {
|
|
let vm: PodcastPlayerViewModel
|
|
let articleUrl: String
|
|
let articleTitle: String
|
|
let claude: ClaudeService
|
|
var stopOnDismiss: Bool = false
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack {
|
|
Spacer()
|
|
phaseContent
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 32)
|
|
.navigationTitle("Podcast")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
if !vm.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func failedView(message: String) -> some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(.red)
|
|
Text("Generation Failed")
|
|
.font(.headline)
|
|
Text(message)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
|
|
private func formatTime(_ s: Double) -> String {
|
|
let total = Int(max(s, 0))
|
|
return String(format: "%d:%02d", total / 60, total % 60)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mini player
|
|
|
|
struct MiniPlayerView: View {
|
|
let vm: PodcastPlayerViewModel
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "waveform")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(.blue)
|
|
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || 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(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)
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
.navigationTitle("Podcasts")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.onAppear { entries = PodcastIndex.all() }
|
|
}
|
|
}
|