tv-anarchy/Sources/TVAnarchy/SearchView.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

133 lines
6.4 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
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.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")
}
}
}
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 {
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)
}
Spacer()
}
.padding(8).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
}
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") { Task { await search.add(r, category: nil) } }
Divider()
ForEach(DownloadsController.addCategories, id: \.self) { cat in
Button("\(cat.capitalized)") { Task { await search.add(r, category: cat) } }
}
} label: { Image(systemName: "plus.circle.fill") }
.menuStyle(.borderlessButton).fixedSize().disabled(!r.addable)
}
.padding(8).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
}
}