Files
linkding-ios/Marks/Views/SearchView.swift
Krishna Kumar af3112530e
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s
feat(podcasts): played/unplayed queue, background generation, share-sheet audio
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>
2026-07-01 11:37:42 -05:00

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