tv-anarchy/Sources/TVAnarchyiOS/DownloadsView.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

249 lines
9.4 KiB
Swift

// Downloads tab: two modes Offline (episodes saved on this device) and
// Torrents (search + transmission management on black).
import SwiftUI
import LilithDesignTokens
struct DownloadsView: View {
enum Mode: String, CaseIterable { case offline = "Offline", torrents = "Torrents" }
@State private var mode: Mode = .offline
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
VStack(spacing: 0) {
Picker("Mode", selection: $mode) {
ForEach(Mode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.padding(AppSpacing.base)
switch mode {
case .offline: OfflineList()
case .torrents: TorrentsList()
}
}
}
.navigationTitle("Downloads")
.navigationDestination(for: PlaybackTarget.self) { PlayerScreen(show: $0.show, episode: $0.episode) }
}
}
}
// MARK: - Offline
private struct OfflineList: View {
@EnvironmentObject private var downloads: DownloadManager
@EnvironmentObject private var settings: BridgeSettings
@State private var packing = false
@State private var packResult: String?
var body: some View {
VStack(spacing: 0) {
packBar
if downloads.entries.isEmpty && downloads.states.isEmpty {
ContentUnavailableView("Nothing downloaded", systemImage: "arrow.down.circle",
description: Text("Download episodes from the Library, or Pack for a flight."))
} else {
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(downloads.entries) { entry in
NavigationLink(value: PlaybackTarget(show: nil, episode: entry.asEpisode)) {
OfflineRow(entry: entry) { downloads.delete(episodeId: entry.episodeId) }
}
.buttonStyle(.plain)
}
}
.padding(AppSpacing.base)
}
}
}
}
/// One-tap flight prep: top up every in-progress show within the budget.
private var packBar: some View {
HStack(spacing: AppSpacing.md) {
Button {
guard let client = settings.client, !packing else { return }
packing = true
Task {
packResult = await FlightPack.run(client: client, downloads: downloads, settings: settings)
packing = false
}
} label: {
Label(packing ? "Packing…" : "Pack now", systemImage: "airplane")
.font(AppTypography.bodySmall(weight: .medium))
}
.buttonStyle(.borderedProminent)
.disabled(packing || settings.client == nil)
if let packResult {
Text(packResult)
.font(AppTypography.caption())
.foregroundStyle(AppColors.textSecondary)
.lineLimit(2)
}
Spacer()
}
.padding(.horizontal, AppSpacing.base)
.padding(.bottom, AppSpacing.sm)
}
}
private struct OfflineRow: View {
let entry: OfflineEntry
let onDelete: () -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
Image(systemName: "play.circle.fill").font(.title2).foregroundStyle(AppColors.primary)
VStack(alignment: .leading, spacing: 2) {
Text("\(entry.show.isEmpty ? entry.label : entry.show)")
.font(AppTypography.bodySmall(weight: .medium))
.foregroundStyle(AppColors.textPrimary).lineLimit(1)
Text("\(entry.code) · \(ByteCountFormatter.string(fromByteCount: entry.bytes, countStyle: .file))")
.font(AppTypography.caption()).foregroundStyle(AppColors.textSecondary)
}
Spacer()
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash").foregroundStyle(AppColors.Semantic.error)
}
.buttonStyle(.plain)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Torrents
private struct TorrentsList: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var query = ""
@State private var results: [SearchResult] = []
@State private var torrents: [Torrent] = []
@State private var searching = false
@State private var errorText: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: AppSpacing.md) {
searchBar
if let errorText {
Text(errorText).font(AppTypography.caption()).foregroundStyle(AppColors.Semantic.error)
}
if !results.isEmpty {
Text("Results").font(AppTypography.h5()).foregroundStyle(AppColors.textPrimary)
ForEach(results) { ResultRow(result: $0, onAdd: { add($0) }) }
}
if !torrents.isEmpty {
Text("Active").font(AppTypography.h5()).foregroundStyle(AppColors.textPrimary)
ForEach(torrents) { t in
TorrentRow(torrent: t, onRemove: { remove(t) })
}
}
}
.padding(AppSpacing.base)
}
.task { await refreshActive() }
}
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(AppColors.textSecondary)
TextField("Search torrents…", text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit { Task { await search() } }
if searching { ProgressView() }
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 10))
}
private func search() async {
guard let client = settings.client, !query.isEmpty else { return }
searching = true; defer { searching = false }
do { results = try await client.searchTorrents(query: query); errorText = nil }
catch { errorText = error.localizedDescription }
}
private func add(_ r: SearchResult) {
guard let client = settings.client, let magnet = r.magnet else { return }
Task {
do { try await client.addTorrent(magnet: magnet, category: nil); await refreshActive() }
catch { errorText = error.localizedDescription }
}
}
private func remove(_ t: Torrent) {
guard let client = settings.client else { return }
Task {
do { try await client.removeTorrent(id: t.id, deleteData: false); await refreshActive() }
catch { errorText = error.localizedDescription }
}
}
private func refreshActive() async {
guard let client = settings.client else { return }
torrents = (try? await client.fetchTorrents()) ?? []
}
}
private struct ResultRow: View {
let result: SearchResult
let onAdd: (SearchResult) -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
VStack(alignment: .leading, spacing: 2) {
Text(result.filename).font(AppTypography.caption()).foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
Text("\(result.size) · ▲\(result.seeders)\(result.leechers) · \(result.source)")
.font(AppTypography.caption()).foregroundStyle(AppColors.textSecondary)
}
Spacer()
Button { onAdd(result) } label: {
Image(systemName: "plus.circle.fill").font(.title3).foregroundStyle(AppColors.primary)
}
.buttonStyle(.plain)
.disabled(result.magnet == nil)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
private struct TorrentRow: View {
let torrent: Torrent
let onRemove: () -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
VStack(alignment: .leading, spacing: 4) {
Text(torrent.name).font(AppTypography.caption()).foregroundStyle(AppColors.textPrimary).lineLimit(1)
ProgressView(value: torrent.percentDone).tint(AppColors.primary)
Text("\(torrent.statusLabel) · \(Int(torrent.percentDone * 100))%")
.font(AppTypography.caption()).foregroundStyle(AppColors.textSecondary)
}
Button(role: .destructive, action: onRemove) {
Image(systemName: "xmark.circle").foregroundStyle(AppColors.Semantic.error)
}
.buttonStyle(.plain)
}
.padding(AppSpacing.md)
.background(AppColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
}
private extension OfflineEntry {
/// Synthesize a BridgeEpisode for the player from an offline entry.
var asEpisode: BridgeEpisode {
BridgeEpisode(
id: episodeId, season: season, episode: episode, label: label,
ext: (filename as NSString).pathExtension, bytes: bytes
)
}
}