Files
Flaro/Flixy/Views/Downloads/DownloadButton.swift
T
ScarriffleandClaude Opus 5 fb418c842d Wiedergabe: der Player spricht nicht mehr mit Plex, sondern mit dem Vertrag
Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts
abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar
kannte.

Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten,
sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet
wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur
aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder
angesprochen und konnte deshalb mit nichts anderem reden.

`MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der
Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau
dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil
es dafür keinen Fall gab.

Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst
(`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er
kopieren kann und welche er neu rechnen muss.

Drei Dinge, die beim Hinsehen auffielen:

- **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei
  Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und
  der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig
  Minuten Fortschritt landet man bei zweiundvierzig.
- **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint
  der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus,
  weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem
  Öffnen fest. Jetzt tut es für die erste Meldung die des Servers.
- **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem
  Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf;
  bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört.

Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht.
Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet
hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag.

Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der
Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung
an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin
das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst
ein richtiger Server.

257 Tests grün.

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

245 lines
8.5 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
@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
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.id }
}
var body: some View {
Button {
act()
} label: {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
.padding(FlixySpacing.snug)
}
.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: "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: "action.retry"
case nil: "downloads.download"
}
}
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.
showsQualityChoice = true
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) }
}
}
private func enqueue(quality: DownloadQuality) {
let service = DownloadService(backend: session.backend, coordinator: downloads)
do {
_ = try service.download(item, mediaIndex: mediaIndex, quality: quality)
} catch {
refusal = error
}
}
private var currentMedia: MediaVersion? {
item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.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)))")
}
}
}