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:
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +402,10 @@ struct MiniPlayerView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if vm.isGenerating {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
} else {
|
||||||
Button {
|
Button {
|
||||||
vm.togglePlayPause()
|
vm.togglePlayPause()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -389,6 +415,7 @@ struct MiniPlayerView: View {
|
|||||||
.frame(width: 36, height: 36)
|
.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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user