feat(podcasts): played/unplayed queue, background generation, share-sheet audio #4
@@ -12,6 +12,7 @@
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
|
||||
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
@@ -156,6 +157,7 @@
|
||||
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>"; };
|
||||
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.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,6 +253,7 @@
|
||||
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
||||
@@ -515,6 +518,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */,
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
|
||||
|
||||
@@ -57,7 +57,7 @@ struct MainContainer: View {
|
||||
TagsView(viewModel: viewModel)
|
||||
}
|
||||
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
.badge(library.unplayedCount)
|
||||
Tab(value: AppTab.search, role: .search) {
|
||||
|
||||
27
Marks/Services/AISummaryStore.swift
Normal file
27
Marks/Services/AISummaryStore.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// URL-keyed cache of AI summaries so views that only know a URL — e.g. the
|
||||
/// deliberately-decoupled Podcasts tab — can show a bookmark's summary without
|
||||
/// holding the full `Bookmark`. Written by `BookmarksViewModel` as it enriches
|
||||
/// and loads bookmarks; read by the episode detail sheet.
|
||||
enum AISummaryStore {
|
||||
private static let key = "aiSummaryByURL"
|
||||
|
||||
static func summary(for url: String) -> String? {
|
||||
let store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
|
||||
let value = store[url]
|
||||
return (value?.isEmpty ?? true) ? nil : value
|
||||
}
|
||||
|
||||
static func set(_ summary: String?, for url: String) {
|
||||
var store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
|
||||
if let summary, !summary.isEmpty {
|
||||
guard store[url] != summary else { return } // no-op if unchanged
|
||||
store[url] = summary
|
||||
} else {
|
||||
guard store[url] != nil else { return }
|
||||
store.removeValue(forKey: url)
|
||||
}
|
||||
UserDefaults.standard.set(store, forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct BookmarkListRow: View {
|
||||
)
|
||||
}
|
||||
.sheet(item: $episodePickerBookmark) { b in
|
||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,8 @@ final class BookmarksViewModel {
|
||||
"tags": bookmark.aiTags ?? []
|
||||
]
|
||||
UserDefaults.standard.set(store, forKey: "aiData")
|
||||
// Mirror by URL so the decoupled Podcasts tab can show summaries.
|
||||
AISummaryStore.set(bookmark.aiSummary, for: bookmark.url)
|
||||
}
|
||||
|
||||
private func restoreAIData() {
|
||||
@@ -311,6 +313,8 @@ final class BookmarksViewModel {
|
||||
if let d = store["\(list[i].id)"] {
|
||||
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
list[i].aiTags = d["tags"] as? [String]
|
||||
// Keep the URL-keyed summary cache populated for existing bookmarks.
|
||||
if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,6 +797,7 @@ struct MiniPlayerView: View {
|
||||
struct PodcastLibraryView: View {
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var showFullPlayer = false
|
||||
@@ -858,7 +859,7 @@ struct PodcastLibraryView: View {
|
||||
)
|
||||
}
|
||||
.sheet(item: $detailEntry) { entry in
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude)
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
|
||||
}
|
||||
}
|
||||
.onAppear { library.reload() }
|
||||
@@ -979,6 +980,7 @@ struct EpisodePickerView: View {
|
||||
let bookmark: Bookmark
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
|
||||
@State private var entries: [PodcastEntry] = []
|
||||
@State private var detailEntry: PodcastEntry?
|
||||
@@ -1035,7 +1037,7 @@ struct EpisodePickerView: View {
|
||||
}
|
||||
}
|
||||
.sheet(item: $detailEntry) { entry in
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude)
|
||||
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
|
||||
}
|
||||
}
|
||||
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
|
||||
@@ -1057,10 +1059,12 @@ struct EpisodeDetailView: View {
|
||||
let entry: PodcastEntry
|
||||
let vm: PodcastPlayerViewModel
|
||||
let claude: ClaudeService
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var showBrowser = false
|
||||
|
||||
private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl }
|
||||
private var isPlayed: Bool {
|
||||
@@ -1068,6 +1072,10 @@ struct EpisodeDetailView: View {
|
||||
}
|
||||
private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) }
|
||||
private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl }
|
||||
private var summary: String? {
|
||||
AISummaryStore.summary(for: entry.parentBookmarkUrl ?? entry.articleUrl)
|
||||
?? AISummaryStore.summary(for: entry.articleUrl)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -1094,6 +1102,14 @@ struct EpisodeDetailView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Section("Summary") {
|
||||
Text(summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
if isCurrent {
|
||||
@@ -1116,10 +1132,15 @@ struct EpisodeDetailView: View {
|
||||
|
||||
if let sourceURL {
|
||||
Section {
|
||||
Button {
|
||||
showBrowser = true
|
||||
} label: {
|
||||
Label("Open Bookmark", systemImage: "book")
|
||||
}
|
||||
Button {
|
||||
openURL(sourceURL)
|
||||
} label: {
|
||||
Label("Open Source", systemImage: "safari")
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
ShareLink(item: sourceURL) {
|
||||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||||
@@ -1144,6 +1165,15 @@ struct EpisodeDetailView: View {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showBrowser) {
|
||||
if let sourceURL {
|
||||
BrowserView(url: sourceURL,
|
||||
title: entry.title ?? domain,
|
||||
claude: claude,
|
||||
podcastPlayer: vm,
|
||||
podcastGenerator: podcastGenerator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user