feat(podcasts): #8 follow-ups — AI summary + in-app Open Bookmark
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:
@@ -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