tv-anarchy/Sources/TVAnarchy/SearchView.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

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