133 lines
6.4 KiB
Swift
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))
|
|
}
|
|
}
|