feat(podcasts): played/unplayed queue, background generation, share-sheet audio #4

Merged
admin merged 5 commits from feat/podcast-app-experience into master 2026-07-01 18:18:57 +00:00
15 changed files with 439 additions and 59 deletions
Showing only changes of commit af3112530e - Show all commits

View File

@@ -20,11 +20,13 @@
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; };
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; };
@@ -33,6 +35,7 @@
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
@@ -110,6 +113,7 @@
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
@@ -149,6 +153,7 @@
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; };
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
@@ -251,7 +256,9 @@
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
FB7728D15C17219ABFF3EFFE /* Log.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
);
@@ -481,6 +488,7 @@
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */,
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */,
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */,
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */,
@@ -528,8 +536,10 @@
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,

View File

@@ -65,18 +65,19 @@ struct MainContainer: View {
.onOpenURL { url in
handleDeepLink(url)
}
.task { applyPendingIntent() }
.task { applyPendingIntent(); viewModel.processPendingPodcastRequests() }
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.onChange(of: scenePhase) { old, new in
// Refresh when returning to the foreground (cold launch is already
// covered by BookmarksView's .task, so only react to a real re-entry).
if new == .active && old != .active {
viewModel.processPendingPodcastRequests()
Task { await viewModel.load() }
}
}
.sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
}
.sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView(

View File

@@ -0,0 +1,119 @@
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 // 01
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)
}
}
}

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 }

View File

@@ -0,0 +1,61 @@
import Foundation
/// Cross-process (main app share extension) queue of podcast-generation
/// requests, plus the user's "auto-generate for these tags" rule. Lives in the
/// shared App Group so the share extension can enqueue work the main app runs
/// the extension itself is too short-lived to generate audio.
enum PodcastRequests {
private static let appGroupId = "group.com.magicive.marks"
private static let pendingKey = "pendingPodcastRequests"
private static let autoTagsKey = "podcastAutoTags"
private static var defaults: UserDefaults? { UserDefaults(suiteName: appGroupId) }
struct Request: Codable {
let url: String
let title: String
}
// MARK: - Pending queue
/// Enqueue a request. De-duplicates on URL so repeated saves don't pile up.
static func enqueue(url: String, title: String) {
var list = pending()
guard !list.contains(where: { $0.url == url }) else { return }
list.append(Request(url: url, title: title))
save(list)
}
static func pending() -> [Request] {
guard let data = defaults?.data(forKey: pendingKey),
let list = try? JSONDecoder().decode([Request].self, from: data) else { return [] }
return list
}
/// Return everything pending and clear the queue.
static func drain() -> [Request] {
let list = pending()
defaults?.removeObject(forKey: pendingKey)
return list
}
private static func save(_ list: [Request]) {
guard let data = try? JSONEncoder().encode(list) else { return }
defaults?.set(data, forKey: pendingKey)
}
// MARK: - Auto-tag rule
/// Tags that auto-trigger podcast generation when a bookmark is saved.
static var autoTags: [String] {
get { defaults?.stringArray(forKey: autoTagsKey) ?? [] }
set { defaults?.set(newValue, forKey: autoTagsKey) }
}
/// Whether saving a bookmark with `tags` should auto-generate a podcast.
static func matchesAutoTag(_ tags: [String]) -> Bool {
let auto = Set(autoTags.map { $0.lowercased() })
guard !auto.isEmpty else { return false }
return tags.contains { auto.contains($0.lowercased()) }
}
}

View File

@@ -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
}
}

View File

@@ -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 }

View File

@@ -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

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)

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -15,6 +15,7 @@ struct ShareView: View {
@State private var tagText = ""
@State private var notes = ""
@State private var readLater = false
@State private var createPodcast = false
@State private var suggestedTags: [String] = [] // linkding auto_tags
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
@@ -151,6 +152,11 @@ struct ShareView: View {
Toggle("Read later", isOn: $readLater)
.font(.subheadline)
Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones")
}
.font(.subheadline)
}
}
@@ -236,6 +242,8 @@ struct ShareView: View {
title = result.metadata?.title ?? ""
suggestedTags = result.autoTags ?? []
}
// Pre-enable the toggle when the current tags match the auto-rule.
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
phase = .editing
await loadSuggestions()
} catch {
@@ -294,6 +302,11 @@ struct ShareView: View {
)
_ = try await api.createBookmark(create)
}
// Hand off podcast generation to the main app (the extension is too
// short-lived to generate audio itself).
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
}
phase = .done
try? await Task.sleep(nanoseconds: 700_000_000)
onClose(true)

View File

@@ -90,6 +90,8 @@ targets:
- path: Marks/Services/LinkdingAPI.swift
- path: Marks/Services/MarksAuth.swift
- path: Marks/Services/Log.swift
# Cross-process podcast request queue: the extension enqueues, the app runs it.
- path: Marks/Services/PodcastRequests.swift
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension