Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit `MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre. Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle` wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und `TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten. `Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein zweites Mal falsch — zu schreiben. Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie waren bereits auseinandergelaufen: derselbe Fehler bekam je nach Bildschirm eine andere Erklärung. Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das jetzt ausdrücklich, damit es niemand später „aufräumt". Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit Plex. Sie folgen als Nächstes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
7.9 KiB
Swift
230 lines
7.9 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.
|
|
private var availableQualities: [DownloadQuality] {
|
|
DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
|
|
}
|
|
|
|
/// 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)))")
|
|
}
|
|
}
|
|
}
|