Files
Flaro/Flixy/Views/Downloads/DownloadButton.swift
T
ScarriffleandClaude Opus 5 93695dc332 Beim Umpacken wird nach der Tonspur gefragt, nicht geraten
Mein Fehler, und ein vermeidbarer. Beim Umpacken legt der Server genau eine
Tonspur in die Datei, und welche, hat bisher allein die Abwägung entschieden
— bei einer Aufnahme mit Audiodeskription die falsche. Nach dem Herunterladen
gibt es nichts mehr zu korrigieren; drei Gigabyte Datenvolumen waren weg.

Wo eine Spur verlorengeht, wird jetzt gefragt. Nur dort: bei einer Datei, die
der Systemplayer ohnehin öffnet, kommt alles mit, und eine Frage ohne Folgen
ist nur ein Klick mehr.

**Der Vorschlag kommt aus dem, was schon bekannt ist.** Die gemerkte Sprache
der Serie zuerst, dann die Serieneinstellung, dann die allgemeine — dieselbe
Abwägung wie beim Abspielen (`AudioTrackChooser`), über denselben Weg. Sie
ist jetzt Vorschlag statt Entscheidung und steht als „· empfohlen" dabei.

Beim Stapel aus der Mehrfachauswahl wird einmal gefragt, nicht je Folge: die
Folgen einer Staffel sind gleich aufgebaut, und zwanzigmal dieselbe Frage
wäre keine Wahl, sondern eine Prüfung der Geduld.

**Was am Server gemessen wurde, und warum es nicht besser geht:**

- `static=true` liefert die Datei unverändert — alle Spuren, aber Matroska;
  die Magie am Dateianfang ist `1a45dfa3`, auch wenn im Pfad `.mp4` steht.
- `stream.mp4` bildet genau eine Tonspur ab.
- Die HLS-Liste des Servers führt **eine** Variante und **keine**
  `EXT-X-MEDIA`-Tonspuren.

Mehrere Tonspuren offline gibt dieser Server auf keinem seiner Wege her. Das
liegt nicht an MP4 — das kann mehrere — und nicht an AVFoundation, sondern am
Transcoder, der je Sitzung eine Spur abbildet.

iOS und tvOS bauen.

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

322 lines
12 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(LanguagePreferences.self) private var languages
@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
/// Die Stufe steht fest, die Tonspur fehlt noch.
///
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
/// zurück.
@State private var pendingQuality: DownloadQuality?
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(choice.available) { quality in
Button(choice.label(for: quality)) { chose(quality) }
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
ForEach(audioStreams) { stream in
Button(audioLabel(stream)) {
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.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 var service: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
private var audioStreams: [MediaStream] {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
return media?.files.first?.audioStreams ?? []
}
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
private var recommended: MediaStream? {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
guard let part = media?.files.first else { return nil }
return service.preferredAudioStream(in: part, of: source)
}
private func audioLabel(_ stream: MediaStream) -> String {
stream == recommended
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
}
/// Nach der Stufe: entweder fragen oder einreihen.
private func chose(_ quality: DownloadQuality) {
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
pendingQuality = quality
} else {
enqueue(quality: quality, audioStream: nil)
}
}
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
pendingQuality = nil
do {
_ = try service.download(
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
)
} catch {
refusal = error
}
}
/// Was zur Wahl steht — siehe `DownloadQualityChoice`.
private var choice: DownloadQualityChoice {
DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend)
}
}
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(LanguagePreferences.self) private var languages
@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, languages: languages
)
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)))")
}
}
}