feat(podcasts): played/unplayed queue, background generation, share-sheet audio
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s

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:
Krishna Kumar
2026-07-01 11:37:42 -05:00
parent f8de444f8f
commit af3112530e
15 changed files with 439 additions and 59 deletions

View File

@@ -220,6 +220,7 @@ struct BrowserView: View {
let initialTitle: String
let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@@ -232,11 +233,12 @@ struct BrowserView: View {
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) {
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self.podcastPlayer = podcastPlayer
self.podcastGenerator = podcastGenerator
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url))
}
@@ -375,13 +377,23 @@ struct BrowserView: View {
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
// Never interrupt current playback: play-and-show only when idle,
// otherwise generate in the background.
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} else {
podcastGenerator.enqueue(
articleUrl: currentUrl,
title: currentTitle,
parentBookmarkUrl: initialURL.absoluteString
)
}
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)