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:
@@ -100,12 +100,7 @@ struct BookmarkListRow: View {
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
} else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ struct BookmarksView: View {
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
if viewModel.podcastGenerator.hasActive {
|
||||
podcastGeneratingBanner
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
|
||||
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
||||
showFullPlayer = true
|
||||
@@ -157,6 +161,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
||||
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
@@ -178,7 +183,7 @@ struct BookmarksView: View {
|
||||
}
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
.bookmarkOnscreen(bookmark)
|
||||
@@ -230,6 +235,40 @@ struct BookmarksView: View {
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var podcastGeneratingBanner: some View {
|
||||
let jobs = viewModel.podcastGenerator.activeJobs
|
||||
return HStack(spacing: 10) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
if let first = jobs.first {
|
||||
Text(first.title.isEmpty ? first.label : first.title)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
.frame(width: 44)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||
|
||||
@@ -18,6 +18,7 @@ final class BookmarksViewModel {
|
||||
private let api: LinkdingAPI
|
||||
let claude = ClaudeService()
|
||||
let podcastPlayer = PodcastPlayerViewModel()
|
||||
let podcastGenerator = PodcastGenerationManager()
|
||||
private var enrichTask: Task<Void, Never>?
|
||||
private let cacheFileUrl: URL
|
||||
|
||||
@@ -29,6 +30,32 @@ final class BookmarksViewModel {
|
||||
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
|
||||
}
|
||||
|
||||
/// True while the player is showing an episode (playing or generating one).
|
||||
var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty }
|
||||
|
||||
/// Handle a headphone tap without ever interrupting current playback.
|
||||
/// If the player is idle, generate-and-play in the foreground and return
|
||||
/// `true` (caller should present the full player). If the player is busy,
|
||||
/// generate in the background — the finished episode lands in the library —
|
||||
/// and return `false`.
|
||||
@discardableResult
|
||||
func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool {
|
||||
if isPlayerBusy {
|
||||
podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return false
|
||||
}
|
||||
podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drain podcast requests queued by the share extension and start generating
|
||||
/// them in the background. Safe to call repeatedly (the queue is cleared).
|
||||
func processPendingPodcastRequests() {
|
||||
for req in PodcastRequests.drain() {
|
||||
podcastGenerator.enqueue(articleUrl: req.url, title: req.title)
|
||||
}
|
||||
}
|
||||
|
||||
func load(useCache: Bool = true) async {
|
||||
if useCache { loadFromCache() }
|
||||
isLoading = true
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -48,7 +48,8 @@ struct SearchView: View {
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ struct SettingsView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
@@ -15,6 +17,21 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("e.g. listen podcast", text: $autoTagText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: autoTagText) { _, new in
|
||||
PodcastRequests.autoTags = new
|
||||
.split(whereSeparator: { $0 == " " || $0 == "," })
|
||||
.map(String.init)
|
||||
}
|
||||
} header: {
|
||||
Text("Auto-Podcast Tags")
|
||||
} footer: {
|
||||
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
|
||||
}
|
||||
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
|
||||
@@ -76,7 +76,8 @@ struct TagBookmarksView: View {
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user