feat(podcasts): played/unplayed queue, background generation, share-sheet audio
Turns the Podcasts tab into a proper podcast-app experience across three features that compose on a shared background-generation service. Background generation (#2) - New PodcastGenerationManager runs generate→poll→download off the player, so producing a new episode never stops current playback. - Headphone taps never interrupt: idle → generate-and-play in the foreground (unchanged); already playing → generate in the background, episode lands in the library. A "Generating…" banner surfaces active jobs. Played/unplayed + queue (#1) - PodcastEntry gains playedAt (old index.json decodes as unplayed). - Podcasts tab splits into Up Next / Played with a Play All queue that auto-advances; finishing marks played; swipe to toggle played state. Share-extension audio (#3) - "Create podcast" toggle on the save card, plus configurable Auto-Podcast Tags in Settings. The extension enqueues a request into the App Group; the main app drains it on launch/foreground and generates in the background. Closes #1 Closes #2 Closes #3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
119
Marks/Services/PodcastGenerationManager.swift
Normal file
119
Marks/Services/PodcastGenerationManager.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Runs podcast generation (generate → poll → download) **off** the player, so a
|
||||
/// new episode can be produced in the background while another one keeps playing.
|
||||
/// The finished audio is written to the on-disk cache and recorded in
|
||||
/// `PodcastIndex`; the optional `onReady` callback lets a caller start playback
|
||||
/// only when nothing else is playing.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PodcastGenerationManager {
|
||||
struct Job: Identifiable {
|
||||
let id: String // articleUrl
|
||||
var articleUrl: String
|
||||
var title: String
|
||||
var progress: Double // 0…1
|
||||
var label: String
|
||||
var failed: Bool = false
|
||||
var error: String?
|
||||
}
|
||||
|
||||
private(set) var jobs: [Job] = []
|
||||
|
||||
private var tasks: [String: Task<Void, Never>] = [:]
|
||||
private let claude = ClaudeService()
|
||||
|
||||
/// Jobs still working (not failed). Drives the "Generating…" banner.
|
||||
var activeJobs: [Job] { jobs.filter { !$0.failed } }
|
||||
var hasActive: Bool { !activeJobs.isEmpty }
|
||||
|
||||
func isGenerating(_ articleUrl: String) -> Bool { tasks[articleUrl] != nil }
|
||||
func job(for articleUrl: String) -> Job? { jobs.first { $0.articleUrl == articleUrl } }
|
||||
|
||||
/// Enqueue a background generation for `articleUrl`. Idempotent per URL.
|
||||
/// If audio is already cached, `onReady` fires immediately and nothing runs.
|
||||
/// `onReady` is invoked on the main actor once audio is available.
|
||||
func enqueue(articleUrl: String,
|
||||
title: String,
|
||||
parentBookmarkUrl: String? = nil,
|
||||
onReady: ((URL, String) -> Void)? = nil) {
|
||||
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
if FileManager.default.fileExists(atPath: cached.path) {
|
||||
onReady?(cached, title)
|
||||
return
|
||||
}
|
||||
if tasks[articleUrl] != nil { return } // already in flight
|
||||
|
||||
jobs.insert(Job(id: articleUrl, articleUrl: articleUrl, title: title,
|
||||
progress: 0, label: "Starting…"), at: 0)
|
||||
Analytics.track("podcast.bg_started", ["url": articleUrl])
|
||||
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let jobId = try await claude.generatePodcast(url: articleUrl)
|
||||
while !Task.isCancelled {
|
||||
let status = try await claude.podcastStatus(jobId: jobId)
|
||||
update(articleUrl) {
|
||||
$0.progress = Double(status.progress) / 100
|
||||
$0.label = PodcastPlayerViewModel.statusLabel(status.status)
|
||||
}
|
||||
if status.isDone {
|
||||
update(articleUrl) { $0.label = "Preparing audio…"; $0.progress = 1 }
|
||||
let url = try await claude.downloadPodcastAudio(
|
||||
jobId: jobId, articleUrl: articleUrl,
|
||||
title: status.title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
let finalTitle = status.title ?? title
|
||||
finish(articleUrl)
|
||||
Analytics.track("podcast.bg_completed", ["url": articleUrl])
|
||||
onReady?(url, finalTitle)
|
||||
return
|
||||
}
|
||||
if status.isFailed {
|
||||
fail(articleUrl, status.error ?? "Generation failed")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
}
|
||||
} catch is CancellationError {
|
||||
finish(articleUrl)
|
||||
} catch {
|
||||
fail(articleUrl, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
tasks[articleUrl] = task
|
||||
}
|
||||
|
||||
/// Cancel an in-flight job and drop it from the list.
|
||||
func cancel(_ articleUrl: String) {
|
||||
tasks[articleUrl]?.cancel()
|
||||
tasks[articleUrl] = nil
|
||||
jobs.removeAll { $0.articleUrl == articleUrl }
|
||||
}
|
||||
|
||||
func dismiss(_ articleUrl: String) { jobs.removeAll { $0.articleUrl == articleUrl } }
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func update(_ url: String, _ mutate: (inout Job) -> Void) {
|
||||
guard let i = jobs.firstIndex(where: { $0.articleUrl == url }) else { return }
|
||||
mutate(&jobs[i])
|
||||
}
|
||||
|
||||
private func finish(_ url: String) {
|
||||
tasks[url] = nil
|
||||
jobs.removeAll { $0.articleUrl == url }
|
||||
}
|
||||
|
||||
private func fail(_ url: String, _ message: String) {
|
||||
tasks[url] = nil
|
||||
update(url) { $0.failed = true; $0.error = message; $0.label = message }
|
||||
Analytics.track("podcast.bg_failed", ["url": url, "error": message])
|
||||
// Failed jobs linger briefly so the user sees them, then self-clear.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
self?.dismiss(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user