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>
249 lines
9.4 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|