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>
120 lines
4.8 KiB
Swift
120 lines
4.8 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|