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:
@@ -53,6 +53,11 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remaining episodes to auto-play after the current one finishes.
|
||||
private(set) var queue: [PodcastEntry] = []
|
||||
/// Retained so queued episodes can be started from the end-of-item observer.
|
||||
private var lastClaude: ClaudeService?
|
||||
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: Any?
|
||||
@@ -66,6 +71,7 @@ final class PodcastPlayerViewModel {
|
||||
// Teardown previous episode (saves its position)
|
||||
if player != nil { stop() }
|
||||
|
||||
lastClaude = claude
|
||||
currentArticleUrl = articleUrl
|
||||
currentArticleTitle = articleTitle
|
||||
|
||||
@@ -112,7 +118,16 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play `entries` in order, auto-advancing through the queue as each finishes.
|
||||
func playQueue(_ entries: [PodcastEntry], claude: ClaudeService) {
|
||||
guard let first = entries.first else { return }
|
||||
// `start` calls `stop`, which clears the queue — set it afterwards.
|
||||
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
|
||||
queue = Array(entries.dropFirst())
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue = []
|
||||
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||||
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||||
}
|
||||
@@ -196,16 +211,29 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to start when playback finishes, like standard media players
|
||||
// On finish: mark the episode played, then auto-advance to the next
|
||||
// queued episode, or reset to the start like a standard media player.
|
||||
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 finished = self.currentArticleUrl
|
||||
PodcastIndex.setPlayed(articleUrl: finished, true)
|
||||
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
|
||||
|
||||
if !self.queue.isEmpty {
|
||||
let next = self.queue.removeFirst()
|
||||
let remaining = self.queue
|
||||
let claude = self.lastClaude ?? ClaudeService()
|
||||
self.start(articleUrl: next.articleUrl,
|
||||
articleTitle: next.title ?? "", claude: claude)
|
||||
self.queue = remaining // `start` cleared it via `stop`
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.currentTime = 0
|
||||
self.player?.seek(to: .zero)
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// Pause on phone calls / Siri; resume when interruption ends
|
||||
@@ -308,7 +336,7 @@ final class PodcastPlayerViewModel {
|
||||
return MPMediaItemArtwork(boundsSize: size) { _ in image }
|
||||
}()
|
||||
|
||||
private static func statusLabel(_ status: String) -> String {
|
||||
static func statusLabel(_ status: String) -> String {
|
||||
switch status {
|
||||
case "queued": return "Queued…"
|
||||
case "fetching": return "Fetching article…"
|
||||
@@ -618,47 +646,30 @@ struct PodcastLibraryView: View {
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(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 {
|
||||
play(entry)
|
||||
} label: {
|
||||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
deleteEntry(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
if !unplayed.isEmpty {
|
||||
Section("Up Next") {
|
||||
ForEach(unplayed) { row(for: $0) }
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
indexSet.forEach { deleteEntry(entries[$0]) }
|
||||
if !played.isEmpty {
|
||||
Section("Played") {
|
||||
ForEach(played) { row(for: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Podcasts")
|
||||
.toolbar {
|
||||
if !entries.isEmpty {
|
||||
ToolbarItem(placement: .topBarTrailing) { EditButton() }
|
||||
if !unplayed.isEmpty {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
vm.playQueue(unplayed, claude: claude)
|
||||
} label: {
|
||||
Label("Play All", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -681,6 +692,64 @@ struct PodcastLibraryView: View {
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.all() }
|
||||
// When the current episode changes (finished, advanced, or stopped),
|
||||
// re-read the index so played items move into the Played section.
|
||||
.onChange(of: vm.currentArticleUrl) { _, _ in entries = PodcastIndex.all() }
|
||||
}
|
||||
|
||||
private var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
|
||||
private var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
|
||||
|
||||
@ViewBuilder
|
||||
private func row(for entry: PodcastEntry) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
play(entry)
|
||||
} label: {
|
||||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
setPlayed(entry, !entry.isPlayed)
|
||||
} label: {
|
||||
if entry.isPlayed {
|
||||
Label("Unplayed", systemImage: "circle")
|
||||
} else {
|
||||
Label("Played", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
.tint(entry.isPlayed ? .gray : .green)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
deleteEntry(entry)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
|
||||
PodcastIndex.setPlayed(articleUrl: entry.articleUrl, played)
|
||||
if let i = entries.firstIndex(where: { $0.articleUrl == entry.articleUrl }) {
|
||||
entries[i].playedAt = played ? Date() : nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isCurrent(_ entry: PodcastEntry) -> Bool {
|
||||
|
||||
Reference in New Issue
Block a user