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

@@ -2,6 +2,10 @@ import Foundation
enum Analytics { enum Analytics {
static func track(_ event: String, _ properties: [String: Any] = [:]) { static func track(_ event: String, _ properties: [String: Any] = [:]) {
// Serialize before crossing the task boundary Data is Sendable, [String: Any] is not
var body: [String: Any] = ["event": event]
if !properties.isEmpty { body["properties"] = properties }
guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return }
Task.detached { Task.detached {
do { do {
let token = try await MarksAuth.validToken() let token = try await MarksAuth.validToken()
@@ -10,9 +14,7 @@ enum Analytics {
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["event": event] request.httpBody = payload
if !properties.isEmpty { body["properties"] = properties }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status < 200 || status > 299 { if status < 200 || status > 299 {

View File

@@ -11,6 +11,7 @@ struct BookmarksView: View {
@State private var editingBookmark: Bookmark? @State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark? @State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var showPodcastLibrary = false
@State private var readingProgress: [String: Double] = ReadingProgress.all() @State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View { var body: some View {
@@ -118,6 +119,11 @@ struct BookmarksView: View {
} label: { } label: {
Label("Enrich All with AI", systemImage: "wand.and.stars") Label("Enrich All with AI", systemImage: "wand.and.stars")
} }
Button {
showPodcastLibrary = true
} label: {
Label("Podcasts", systemImage: "headphones")
}
} label: { } label: {
Image(systemName: "sparkles") Image(systemName: "sparkles")
} }
@@ -150,7 +156,7 @@ struct BookmarksView: View {
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
VStack(spacing: 8) { VStack(spacing: 8) {
if viewModel.podcastPlayer.player != nil { if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) { MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true showFullPlayer = true
} }
@@ -160,7 +166,7 @@ struct BookmarksView: View {
enrichmentBanner enrichmentBanner
} }
} }
.animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil) .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
@@ -193,6 +199,9 @@ struct BookmarksView: View {
stopOnDismiss: false stopOnDismiss: false
) )
} }
.sheet(isPresented: $showPodcastLibrary) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
.refreshable { .refreshable {
await viewModel.load() await viewModel.load()
} }

View File

@@ -43,13 +43,21 @@ final class PodcastPlayerViewModel {
var currentArticleUrl: String = "" var currentArticleUrl: String = ""
var currentArticleTitle: 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 pollingTask: Task<Void, Never>?
private var timeObserver: Any? private var timeObserver: Any?
private var endObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast" private var currentPodcastTitle: String = "Marks Podcast"
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) { func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
// Idempotent don't restart the same article if already playing // Idempotent don't restart if already generating or playing this article
if currentArticleUrl == articleUrl && player != nil { return } if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
// Teardown previous episode (saves its position) // Teardown previous episode (saves its position)
if player != nil { stop() } if player != nil { stop() }
@@ -105,6 +113,8 @@ final class PodcastPlayerViewModel {
pollingTask?.cancel() pollingTask?.cancel()
player?.pause() player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) } if let obs = timeObserver { player?.removeTimeObserver(obs) }
if let obs = endObserver { NotificationCenter.default.removeObserver(obs) }
endObserver = nil
player = nil player = nil
isPlaying = false isPlaying = false
currentTime = 0 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) let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
guard let self else { return } guard let self else { return }
@@ -243,7 +265,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() } Button("Done") { dismiss() }
} }
if vm.player != nil { if !vm.currentArticleUrl.isEmpty {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: { Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle") Image(systemName: "stop.circle")
@@ -365,7 +387,7 @@ struct MiniPlayerView: View {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22) .frame(width: 22)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -380,15 +402,20 @@ struct MiniPlayerView: View {
Spacer() Spacer()
Button { if vm.isGenerating {
vm.togglePlayPause() ProgressView()
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
.frame(width: 36, height: 36) .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 { Button {
vm.stop() vm.stop()
@@ -435,3 +462,62 @@ struct MiniPlayerView: View {
return mins > 0 ? "\(mins) min left" : "Almost done" 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() }
}
}