feat(podcasts): #8 follow-ups — AI summary + in-app Open Bookmark
All checks were successful
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (pull_request) Successful in 18s

Resolves the two deviations from the episode detail sheet:

- AI summary now shows in the detail sheet. Added a URL-keyed AISummaryStore
  that BookmarksViewModel populates as it enriches/loads bookmarks, so the
  decoupled Podcasts tab can surface a summary from just the episode URL —
  without recoupling to BookmarksViewModel.
- "Open Bookmark" opens the in-app BrowserView as a nested sheet (generator
  threaded through PodcastLibraryView / EpisodePickerView), alongside the
  existing "Open in Safari" and Share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-07-01 13:16:16 -05:00
parent a155c15e17
commit 1f49a7dcbe
6 changed files with 70 additions and 5 deletions

View File

@@ -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 */,

View File

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

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

View File

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

View File

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

View File

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