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