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>
103 lines
3.9 KiB
Swift
103 lines
3.9 KiB
Swift
import SwiftUI
|
|
|
|
struct SearchView: View {
|
|
@Bindable var viewModel: BookmarksViewModel
|
|
|
|
@State private var searchText = ""
|
|
@State private var useSemanticSearch = false
|
|
@State private var semanticResults: [Bookmark]? = nil
|
|
@State private var searchTask: Task<Void, Never>?
|
|
@State private var browsingBookmark: Bookmark?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(results) { bookmark in
|
|
BookmarkListRow(
|
|
bookmark: bookmark,
|
|
viewModel: viewModel,
|
|
onOpen: { browsingBookmark = bookmark }
|
|
)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.navigationTitle("Search")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Toggle(isOn: $useSemanticSearch) {
|
|
Label("Semantic", systemImage: "sparkles")
|
|
}
|
|
.toggleStyle(.button)
|
|
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
|
}
|
|
}
|
|
.overlay {
|
|
if searchText.isEmpty {
|
|
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
|
} else if results.isEmpty && !viewModel.isLoading {
|
|
ContentUnavailableView.search(text: searchText)
|
|
}
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
|
.sheet(item: $browsingBookmark) { bookmark in
|
|
if let url = URL(string: bookmark.url) {
|
|
BrowserView(
|
|
url: url,
|
|
title: bookmark.displayTitle,
|
|
claude: viewModel.claude,
|
|
podcastPlayer: viewModel.podcastPlayer,
|
|
podcastGenerator: viewModel.podcastGenerator
|
|
) {
|
|
await viewModel.archive(bookmark)
|
|
}
|
|
.bookmarkOnscreen(bookmark)
|
|
}
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
|
.onChange(of: searchText) { _, _ in scheduleSearch() }
|
|
.onAppear { consumeIntentSearch() }
|
|
.onChange(of: IntentRouter.shared.searchRequest) { _, _ in consumeIntentSearch() }
|
|
}
|
|
|
|
/// Pulls a query handed over by `SearchMarksIntent` into the search field.
|
|
private func consumeIntentSearch() {
|
|
guard let query = IntentRouter.shared.searchRequest else { return }
|
|
searchText = query
|
|
IntentRouter.shared.searchRequest = nil
|
|
}
|
|
|
|
private var results: [Bookmark] {
|
|
if let semantic = semanticResults { return semantic }
|
|
guard !searchText.isEmpty else { return [] }
|
|
let q = searchText.lowercased()
|
|
return viewModel.bookmarks.filter { b in
|
|
b.displayTitle.lowercased().contains(q) ||
|
|
b.url.lowercased().contains(q) ||
|
|
b.tagNames.contains { $0.lowercased().contains(q) } ||
|
|
(b.aiTags ?? []).contains { $0.lowercased().contains(q) } ||
|
|
(b.aiSummary ?? "").lowercased().contains(q)
|
|
}
|
|
}
|
|
|
|
private func scheduleSearch() {
|
|
semanticResults = nil
|
|
searchTask?.cancel()
|
|
guard !searchText.isEmpty, useSemanticSearch else { return }
|
|
let claude = viewModel.claude
|
|
searchTask = Task {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
guard !Task.isCancelled else { return }
|
|
let q = searchText
|
|
let all = viewModel.bookmarks
|
|
do {
|
|
let ranked = try await claude.semanticSearch(query: q, in: all)
|
|
await MainActor.run { semanticResults = ranked }
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|