Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
9.8 KiB
Swift
200 lines
9.8 KiB
Swift
import SwiftUI
|
|
import TVAnarchyCore
|
|
|
|
/// Unified Search: one box, three answers — what you OWN (library), what you're
|
|
/// GETTING (transfers), and what you can GET (torrents).
|
|
struct SearchView: View {
|
|
@Bindable var search: SearchController
|
|
@Bindable var player: PlayerController
|
|
@Bindable var library: LibraryController
|
|
/// Open the Library tab (after selecting a show) so a hit goes to its page.
|
|
let openLibrary: () -> Void
|
|
@State private var pendingAdd: (TorrentResult, String?)?
|
|
@State private var addWarning: String?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
if search.searching { ProgressView("Searching torrents…").controlSize(.small) }
|
|
|
|
if !search.libraryMatches.isEmpty {
|
|
section("In your library", systemImage: "internaldrive", count: search.libraryMatches.count) {
|
|
ForEach(search.libraryMatches) { show in libraryRow(show) }
|
|
}
|
|
}
|
|
if !search.transferMatches.isEmpty {
|
|
section("Downloading / downloaded", systemImage: "arrow.down.circle", count: search.transferMatches.count) {
|
|
ForEach(search.transferMatches) { row in transferRow(row) }
|
|
}
|
|
}
|
|
if let err = search.torrentError, search.torrentResults.isEmpty {
|
|
CopyableText(text: err)
|
|
}
|
|
if !search.torrentCollections.isEmpty {
|
|
section("Collections (v1)", systemImage: "square.grid.2x2", count: search.torrentCollections.count) {
|
|
ForEach(Array(search.torrentCollections.keys.sorted()), id: \.self) { key in
|
|
collectionCard(key: key, results: search.torrentCollections[key] ?? [])
|
|
}
|
|
}
|
|
} else if !search.torrentResults.isEmpty {
|
|
section("Available to download", systemImage: "magnifyingglass", count: search.torrentResults.count) {
|
|
ForEach(search.torrentResults) { r in torrentRow(r) }
|
|
}
|
|
}
|
|
if search.didSearch && !search.searching && search.libraryMatches.isEmpty
|
|
&& search.transferMatches.isEmpty && search.torrentResults.isEmpty && search.torrentError == nil {
|
|
Text("Nothing found for “\(search.query)”.").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.navigationTitle("Search")
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
HeaderSearchField(text: $search.query,
|
|
prompt: "Search library, downloads & torrents") {
|
|
Task { await search.search() }
|
|
}
|
|
}
|
|
ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) }
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if let a = search.lastAction {
|
|
Text(a).font(.callout).padding(.horizontal, 14).padding(.vertical, 10)
|
|
.background(.thinMaterial, in: Capsule()).padding(.bottom, 16)
|
|
.onTapGesture { copyToClipboard(a) }.help("Click to copy")
|
|
}
|
|
}
|
|
.confirmationDialog("Add torrent?", isPresented: Binding(
|
|
get: { pendingAdd != nil },
|
|
set: { if !$0 { pendingAdd = nil; addWarning = nil } }
|
|
), titleVisibility: .visible) {
|
|
if let pending = pendingAdd {
|
|
Button("Add anyway", role: .destructive) {
|
|
let item = pending
|
|
pendingAdd = nil; addWarning = nil
|
|
Task { await search.add(item.0, category: item.1) }
|
|
}
|
|
.help("Proceed even though it overlaps library or current transfers")
|
|
}
|
|
Button("Cancel", role: .cancel) { pendingAdd = nil; addWarning = nil }
|
|
.help("Do not add the torrent")
|
|
} message: {
|
|
if let addWarning { Text(addWarning) }
|
|
}
|
|
}
|
|
|
|
private func section<Content: View>(_ title: String, systemImage: String, count: Int,
|
|
@ViewBuilder _ rows: () -> Content) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label("\(title) · \(count)", systemImage: systemImage).font(.headline)
|
|
VStack(spacing: 6) { rows() }
|
|
}
|
|
}
|
|
|
|
private func libraryRow(_ show: CachedShow) -> some View {
|
|
// Tapping a library hit opens its show page (where you choose what to play),
|
|
// rather than immediately resuming an episode.
|
|
Button {
|
|
library.selectedCategory = library.type(of: show.category)
|
|
library.selectedShow = show
|
|
openLibrary()
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: show.kind == .movie ? "film" : "tv").foregroundStyle(.secondary).frame(width: 22)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(show.name).font(.callout)
|
|
Text([show.category.isEmpty ? "" : LibraryConfig.label(library.type(of: show.category)), show.countSummary].filter { !$0.isEmpty }.joined(separator: " · "))
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
|
}
|
|
.padding(8).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func transferRow(_ row: TorrentRow) -> some View {
|
|
let misplaced = SearchMatcher.isMisplaced(row)
|
|
return HStack(spacing: 10) {
|
|
Image(systemName: row.isComplete ? "checkmark.circle.fill" : "arrow.down.circle")
|
|
.foregroundStyle(row.isComplete ? .green : .blue).frame(width: 22)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(row.name).font(.callout).lineLimit(1)
|
|
Text(row.isComplete ? "complete · \(row.sizeText)"
|
|
: "\(Int(row.progress * 100))% · \(row.sizeText)\(row.etaText.isEmpty ? "" : " · ETA \(row.etaText)")")
|
|
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
|
if misplaced, let dir = row.downloadDir {
|
|
Text("Misplaced: \(dir)").font(.caption2).foregroundStyle(.orange).lineLimit(1)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(8).background(.quaternary.opacity(misplaced ? 0.55 : 0.4), in: RoundedRectangle(cornerRadius: 8))
|
|
.help(misplaced ? "Files aren't under a folder matching this title — library may not list it correctly." : "")
|
|
}
|
|
|
|
private func torrentRow(_ r: TorrentResult) -> some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(r.filename).lineLimit(2).font(.callout)
|
|
HStack(spacing: 8) {
|
|
Label("\(r.seeders)", systemImage: "arrow.up.circle.fill")
|
|
.foregroundStyle(r.seeders >= 20 ? .green : r.seeders >= 5 ? .yellow : .secondary)
|
|
Text(r.size).foregroundStyle(.secondary)
|
|
Text(r.source).foregroundStyle(.tertiary)
|
|
}
|
|
.font(.caption.monospacedDigit())
|
|
}
|
|
Spacer()
|
|
Menu {
|
|
Button("Default location") { queueAdd(r, category: nil) }
|
|
.help("Add using black's default download directory")
|
|
Divider()
|
|
ForEach(DownloadsController.addCategories, id: \.self) { cat in
|
|
Button("→ \(cat.capitalized)") { queueAdd(r, category: cat) }
|
|
.help("Route files into media/\(cat) on black")
|
|
}
|
|
} label: { Image(systemName: "plus.circle.fill") }
|
|
.menuStyle(.borderlessButton).fixedSize().disabled(!r.addable)
|
|
.help("Add this torrent to transmission on black")
|
|
}
|
|
.padding(8).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
private func queueAdd(_ result: TorrentResult, category: String?) {
|
|
if let warning = search.addWarning(for: result, category: category) {
|
|
pendingAdd = (result, category)
|
|
addWarning = warning
|
|
return
|
|
}
|
|
Task { await search.add(result, category: category) }
|
|
}
|
|
|
|
// v1 collections card: summary + one-tap best-seeded (or menu for choice). Anchored by title key.
|
|
private func collectionCard(key: String, results: [TorrentResult]) -> some View {
|
|
let best = results.first ?? results.first!
|
|
let count = results.count
|
|
return HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(best.filename).font(.callout).lineLimit(1) // representative
|
|
Text("\(count) release\(count==1 ? "" : "s") · best \(best.seeders) seeders").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Menu {
|
|
ForEach(results) { r in
|
|
Button("\(r.seeders)↑ · \(r.size) · \(r.source)") { queueAdd(r, category: nil) }
|
|
.help("Add this specific release")
|
|
}
|
|
} label: {
|
|
Label("Add best", systemImage: "plus.circle.fill")
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.disabled(!best.addable)
|
|
.help("Add the highest-seeded available release for this title (or choose another)")
|
|
}
|
|
.padding(8).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|