Podcast UX baseline: mini player during generation, end-of-track reset, library view

- Mini player shows as soon as generation starts (not just when audio ready)
- Idempotency: tapping headphones again during generation is a no-op
- Playback resets to position 0 on completion (standard player behavior)
- Stop button in full player visible during generation
- PodcastLibraryView lists all generated podcasts
- Sparkles menu gains Podcasts entry
- Fix AnalyticsService Sendable error (serialize before Task.detached boundary)
This commit is contained in:
Krishna Kumar
2026-05-23 00:46:46 -05:00
parent 18423a7633
commit ab72611d7b
3 changed files with 113 additions and 16 deletions

View File

@@ -43,13 +43,21 @@ final class PodcastPlayerViewModel {
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 the same article if already playing
if currentArticleUrl == articleUrl && player != nil { return }
// 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() }
@@ -105,6 +113,8 @@ final class PodcastPlayerViewModel {
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
@@ -158,6 +168,18 @@ final class PodcastPlayerViewModel {
}
}
// 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 }
@@ -243,7 +265,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
if vm.player != nil {
if !vm.currentArticleUrl.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle")
@@ -365,7 +387,7 @@ struct MiniPlayerView: View {
Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
@@ -380,15 +402,20 @@ struct MiniPlayerView: View {
Spacer()
Button {
vm.togglePlayPause()
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
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)
}
.buttonStyle(.plain)
Button {
vm.stop()
@@ -435,3 +462,62 @@ struct MiniPlayerView: View {
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() }
}
}