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

@@ -7,6 +7,11 @@ struct PodcastEntry: Codable, Identifiable {
var title: String?
let createdAt: Date
var parentBookmarkUrl: String?
/// When the episode was finished. `nil` means unplayed/new. Absent in
/// pre-existing index files, which decode as `nil` (i.e. unplayed).
var playedAt: Date? = nil
var isPlayed: Bool { playedAt != nil }
}
enum PodcastIndex {
@@ -49,6 +54,14 @@ enum PodcastIndex {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
}
/// Mark an episode played (default) or back to unplayed. No-op if unknown.
static func setPlayed(articleUrl: String, _ played: Bool = true) {
var entries = all()
guard let i = entries.firstIndex(where: { $0.articleUrl == articleUrl }) else { return }
entries[i].playedAt = played ? Date() : nil
save(entries)
}
static func remove(articleUrl: String) {
var entries = all()
entries.removeAll { $0.articleUrl == articleUrl }