Files
Flaro/Flixy/Views/Downloads/DownloadButton.swift
T
ScarriffleandClaude Opus 5 ad556c9034 Ein Knopf an jeder Folge, und die Suche spielt nicht mehr sofort ab
**Staffelansicht: Download je Folge.** Für den Fall, den du beschrieben
hast — Staffel halb gesehen, Rest nachholen. Ein Tipp öffnet dieselbe
Qualitätsauswahl wie auf der Detailseite.

Der Knopf steht **neben** dem Link, nicht darin: ein `Button` im Label
eines `NavigationLink` löst auf iOS nie aus, und `.plain` lässt das
lautlos geschehen. Dieselbe Falle steht in `QuickPlayTile` schon
beschrieben. Als Geschwister in einer Reihe teilt das Layout den Platz
auf, und der Titel kann gar nicht erst unter dem Knopf verschwinden.

Die Kinderliste trägt keine Dateiangaben — ohne Nachladen wäre die
Qualitätsliste leer und die Prüfung auf freien Speicher übersprungen.
`DownloadButton` holt sie deshalb beim ersten Tipp nach (`loadsDetails`),
mit Kreisel und fester Breite, damit die Zeile nicht springt. Nicht beim
Erscheinen: zwanzig Zeilen wären zwanzig Anfragen für einen Knopf, den man
meistens nicht drückt.

Nur bei Folgen. Für eine ganze Staffel gibt es den Knopf oben schon.

**Suche: Antippen führt auf die Seite.** Die Suche baut dieselbe Reihe wie
die Startseite und war damit auch dieselbe Geste. Wer sucht, weiß aber
noch nicht, was er sehen will — der erste Treffer ist eine Vermutung des
Servers. Abspielen steht weiter im Menü unter langem Drücken.

Dabei aufgefallen: `SearchView` hinterlegte `flixyItemChanged` nicht,
anders als Startseite und Mediathek. „Als gesehen markieren" wirkte dort
am Server, aber die Kachel lud nie neu — es sah aus, als wäre nichts
passiert.

Im Simulator geprüft: Knopf an jeder Folge, Auswahl mit echten Größen
(„Original · HD · 906,9 MB"), Suchtreffer öffnet die Detailseite. iOS und
tvOS bauen, 489 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 21:05:49 +02:00

302 lines
11 KiB
Swift

import SwiftUI
import FlixyDesign
import FlixyDownloads
import MediaKit
import PlexKit
/// Der Download-Knopf auf einer Detailseite.
///
/// Zeigt vier Zustände in einem Knopf: nicht geladen, lädt gerade, offline
/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die
/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer.
struct DownloadButton: View {
let item: MediaItem
var mediaIndex: Int = 0
/// Ob die Dateiangaben erst noch geholt werden müssen.
///
/// **Für Zeilen, die nur eine Zusammenfassung tragen.** Die Folgenliste
/// einer Staffel kommt ohne Fassungen und ohne Dateigrößen — mit so einem
/// Titel wäre die Qualitätsliste leer und die erwartete Größe null, was
/// die Prüfung auf freien Speicherplatz stillschweigend überspringt.
/// `DownloadService.downloadAll` holt jede Folge aus genau diesem Grund
/// einzeln nach.
///
/// Geholt wird erst beim ersten Tipp: zwanzig Zeilen beim Erscheinen
/// nachzuladen wären zwanzig Anfragen für einen Knopf, den man meistens
/// nicht drückt.
var loadsDetails: Bool = false
@Environment(FlixySession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@State private var refusal: DownloadRefusal?
@State private var showsQualityChoice = false
/// Der nachgeholte Titel — gilt ab dann statt `item`.
@State private var detailed: MediaItem?
@State private var isLoadingDetails = false
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.id }
}
/// Der Titel, aus dem Fassungen und Größen gelesen werden.
///
/// Die Kennung ist in beiden dieselbe — nur die Dateiangaben fehlen dem
/// einen.
private var source: MediaItem { detailed ?? item }
var body: some View {
Button {
act()
} label: {
Group {
if isLoadingDetails {
ProgressView().controlSize(.small)
} else {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
}
}
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
.frame(width: 22, height: 22)
.padding(FlixySpacing.snug)
}
.disabled(isLoadingDetails)
.buttonStyle(.bordered)
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
.accessibilityLabel(Text(label))
.confirmationDialog(
Text("downloads.quality.title"),
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
ForEach(availableQualities) { quality in
Button(qualityLabel(quality)) { enqueue(quality: quality) }
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.alert(
Text("downloads.refused.title"),
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
) {
Button("action.close") { refusal = nil }
} message: {
if let refusal { Text(refusal.explanation) }
}
}
private var symbol: String {
switch existing?.state {
case .completed: "checkmark.circle.fill"
case .downloading, .queued, .waiting: "stop.circle"
case .paused: "play.circle"
case .failed: continues ? "play.circle" : "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90"
case nil: "arrow.down.circle"
}
}
private var label: LocalizedStringKey {
switch existing?.state {
case .completed: "downloads.available"
case .downloading, .queued, .waiting: "downloads.cancel"
case .paused: "downloads.resume"
case .failed: continues ? "downloads.resume" : "action.retry"
case nil: "downloads.download"
}
}
/// Ob ein Tipp dort weitermacht, wo es aufgehört hat.
///
/// Ein gescheiterter Download hat seinen Wiederaufnahme-Datensatz nur,
/// wenn ihn das Netz unterbrochen hat — hat der Server abgelehnt, fängt
/// es von vorn an, und dann heißt der Knopf auch so.
private var continues: Bool { existing?.resumeData != nil }
private func act() {
guard let existing else {
// Vor dem Laden fragen. Der Unterschied zwischen einem
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
guard loadsDetails, detailed == nil else {
showsQualityChoice = true
return
}
Task { await loadDetailsThenAsk() }
return
}
switch existing.state {
case .completed:
// Ein fertiger Download wird hier nicht angetastet. Löschen geht
// über die Downloads-Ansicht — ein Fehlgriff in der Kopfzeile
// soll nicht 40 GB verwerfen.
break
case .downloading, .queued, .waiting:
Task { await downloads.cancel(existing) }
case .paused, .failed:
Task { await downloads.resume(existing) }
}
}
/// Holt die Dateiangaben nach und fragt dann nach der Qualität.
///
/// Scheitert das Nachholen, wird trotzdem gefragt — dann steht nur
/// „Original" zur Wahl, und die Ablehnung kommt mit einer Begründung aus
/// `DownloadService`. Das ist besser als ein Knopf, der nach einem Tipp
/// kommentarlos nichts tut.
private func loadDetailsThenAsk() async {
isLoadingDetails = true
detailed = try? await session.backend?.item(id: item.id)
isLoadingDetails = false
showsQualityChoice = true
}
private func enqueue(quality: DownloadQuality) {
let service = DownloadService(backend: session.backend, coordinator: downloads)
do {
_ = try service.download(source, mediaIndex: mediaIndex, quality: quality)
} catch {
refusal = error
}
}
private var currentMedia: MediaVersion? {
source.versions.indices.contains(mediaIndex) ? source.versions[mediaIndex] : source.preferredVersion
}
/// Nur Stufen, die unter der Auflösung der Datei liegen.
///
/// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt.
/// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die
/// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage.
private var availableQualities: [DownloadQuality] {
let all = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
guard canDownloadCompressed else { return [.original] }
return all
}
private var canDownloadCompressed: Bool {
session.backend?.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: 8_000,
resolution: nil
) != nil
}
/// Die Beschriftung samt Auflösung und geschätzter Größe.
///
/// Beim Original steht dabei, was es überhaupt ist: „Original · 720p".
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
/// herunterrechnet oder nicht.
private func qualityLabel(_ quality: DownloadQuality) -> String {
let originalBytes = currentMedia?.files.first?.size ?? 0
let bytes = quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: originalBytes
)
var name = String(localized: quality.localizedName)
if quality == .original, let resolution = currentMedia?.resolutionLabel {
name += " · \(resolution)"
}
guard bytes > 0 else { return name }
let size = bytes.formatted(.byteCount(style: .file))
return quality == .original
? "\(name) · \(size)"
: String(localized: "downloads.quality.approx \(name) \(size)")
}
}
extension DownloadQuality {
var localizedName: String.LocalizationValue {
switch self {
case .original: "quality.original"
case .fullHD: "quality.fullHD"
case .hd: "quality.hd"
case .low: "quality.lowRes"
}
}
}
/// Der Knopf für eine ganze Staffel oder Serie.
struct DownloadAllButton: View {
let container: MediaItem
@Environment(FlixySession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
@State private var isWorking = false
@State private var summary: String?
var body: some View {
Button {
Task { await downloadAll() }
} label: {
if isWorking {
ProgressView().controlSize(.small)
} else {
Label(
container.kind == .show ? "downloads.allEpisodes" : "downloads.season",
systemImage: "arrow.down.circle"
)
}
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
.disabled(isWorking)
.alert(
Text("downloads.batch.title"),
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
) {
Button("action.close") { summary = nil }
} message: {
if let summary { Text(summary) }
}
}
private func downloadAll() async {
isWorking = true
defer { isWorking = false }
let service = DownloadService(backend: session.backend, coordinator: downloads)
let result = await service.downloadAll(in: container)
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
if !result.refused.isEmpty {
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
// er für alle übrigen derselbe.
lines.append(result.refused[0].1.explanation)
}
if result.unavailable > 0 {
lines.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
}
summary = lines.joined(separator: "\n")
}
}
extension DownloadRefusal {
/// Der Grund im Klartext.
var explanation: String {
switch self {
case .alreadyExists:
String(localized: "downloads.refused.exists")
case .storageLimitReached(let limit, let used):
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
case .noDownloadableFile(let reason):
reason
case .insufficientDeviceSpace(let required, let available):
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
}
}
}