Files
Flaro/Flixy/Views/Downloads/DownloadsView.swift
T
ScarriffleandClaude Opus 5 26c909a2e2 Mini-Player: die Wiedergabe überlebt das Zurückgehen
Wer im Fernsehen etwas sieht und nebenher nachsehen will, was sonst
läuft, musste bisher abbrechen. Plex' eigene Handy-Apps können das auch
nicht — kein Grund, es hier genauso zu machen.

Der eigentliche Umbau ist nicht die Kachel, sondern der Besitz. Die
Wiedergabe hing an der Sichtbarkeit der Ansicht, und zwar dreifach: das
Modell war `@State` im Player, `onDisappear` hat den Motor abgeräumt, und
die Bildebene entstand in `makeUIView` neu. Zurückgehen hieß deshalb:
Motor tot, Transcoder aus, beim Live-TV der Tuner freigegeben.

Jetzt gehört das Modell der Wurzelansicht, die immer da ist. Verkleinern
und Vergrößern wechseln nur noch die Darstellung; beendet wird die
Wiedergabe an genau einer Stelle, und zwar weil jemand es will.

Die Bildebene hält das Modell selbst — dasselbe Muster, das VLC seit
jeher verwendet. Dieselbe `UIView` wandert zwischen Kachel und Vollbild,
statt neu zu entstehen; sonst setzte das Bild bei jedem Wechsel neu an.
Bild-in-Bild wandert mit und bekommt endlich einen Delegaten: bisher war
es eine Einbahnstraße, aus der man nicht in die App zurückkam.

Die Kachel schwebt und lässt sich in jede Ecke ziehen, statt als Leiste
unten zu kleben — eine Leiste nähme jeder Liste Platz weg und müsste in
jedem Bildschirm bedacht werden. Die Eckenberechnung steht in
MiniPlayerPlacement und ist geprüft: in einer View wäre eine Kachel, die
halb aus dem Bild ragt, erst auf dem Gerät aufgefallen.

Der Pfeil oben verkleinert, ein neuer Knopf daneben beendet wirklich.

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

312 lines
11 KiB
Swift

import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
/// Was offline ist und was gerade geladen wird.
struct DownloadsView: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackCoordinator.self) private var playback
@Environment(NetworkConditions.self) private var network
@Environment(\.flixyAccent) private var accent
@State private var itemPendingRemoval: DownloadItem?
var body: some View {
Group {
if downloads.items.isEmpty {
ContentUnavailableView {
Label("downloads.empty.title", systemImage: "arrow.down.circle")
} description: {
Text("downloads.empty.description")
}
} else {
list
}
}
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.downloads"))
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
Text("downloads.removeConfirm"),
isPresented: Binding(
get: { itemPendingRemoval != nil },
set: { if !$0 { itemPendingRemoval = nil } }
),
titleVisibility: .visible
) {
Button("action.delete", role: .destructive) {
if let item = itemPendingRemoval {
Task { await downloads.cancel(item) }
}
itemPendingRemoval = nil
}
Button("action.cancel", role: .cancel) { itemPendingRemoval = nil }
}
}
/// Was die Übertragung gerade aufhält.
private var hold: TransferHold {
TransferHold.current(
isWiFiOnly: downloads.isWiFiOnly,
isReachable: network.isReachable,
isExpensive: network.isExpensive
)
}
private var list: some View {
List {
StorageSection()
// Erst was läuft, dann was fertig ist. Wer die Ansicht öffnet,
// während etwas lädt, will meistens genau das sehen.
if !active.isEmpty {
Section("downloads.active") {
// Die Hintergrund-Sitzung wartet in diesen Fällen von
// sich aus: kein Fehler, nur Stillstand. Ohne diesen
// Hinweis sieht ein wartender Download genauso aus wie
// ein hängender.
if hold.isHolding { HoldNotice(hold: hold) }
ForEach(active) { item in
DownloadRow(
item: item,
hold: hold,
onRemove: { itemPendingRemoval = item }
)
}
}
}
ForEach(completedGroups, id: \.title) { group in
Section(group.title) {
ForEach(group.items) { item in
// Fertige Downloads wartet nichts mehr auf.
DownloadRow(item: item, hold: .none, onRemove: { itemPendingRemoval = item })
.contentShape(.rect)
.onTapGesture { play(item) }
}
}
}
}
.scrollContentBackground(.hidden)
.listRowBackground(Color.flixySurface)
}
private var active: [DownloadItem] {
downloads.items.filter { $0.state != .completed }
}
/// Fertige Downloads, nach Serie gruppiert; Filme stehen zusammen.
private var completedGroups: [(title: String, items: [DownloadItem])] {
let completed = downloads.items.filter { $0.state == .completed }
let grouped = Dictionary(grouping: completed) {
$0.seriesTitle ?? String(localized: "downloads.movies")
}
return grouped
.map { (title: $0.key, items: $0.value.sorted(by: episodeOrder)) }
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
}
private func episodeOrder(_ lhs: DownloadItem, _ rhs: DownloadItem) -> Bool {
(lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) < (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
}
private func play(_ item: DownloadItem) {
guard item.state == .completed else { return }
playback.play(PlaybackRequest(
ratingKey: item.ratingKey,
title: item.title,
mediaIndex: 0,
startTime: .zero
))
}
}
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
private struct StorageSection: View {
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
Section {
HStack {
Text("downloads.used")
.foregroundStyle(Color.flixyTextSecondary)
Spacer()
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
.foregroundStyle(Color.flixyTextPrimary)
}
if let limit = downloads.storageLimitBytes, limit > 0 {
ProgressView(value: min(Double(downloads.usedBytes) / Double(limit), 1))
.tint(accent)
.accessibilityLabel(Text("downloads.used"))
}
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
}
}
}
/// Eine Zeile in der Downloads-Ansicht.
private struct DownloadRow: View {
let item: DownloadItem
let hold: TransferHold
let onRemove: () -> Void
@Environment(DownloadCoordinator.self) private var downloads
@Environment(\.flixyAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
HStack(spacing: FlixySpacing.regular) {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
Spacer()
action
}
if item.state != .completed {
progress
}
// Nur bei dem, was tatsächlich warten würde. Ein vom Nutzer
// angehaltener Download wartet nicht auf WLAN, sondern auf ihn.
if hold.isHolding, item.state == .downloading || item.state == .queued {
Label(
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
if let message = item.failureMessage {
Text(message)
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
.lineLimit(2)
}
}
.padding(.vertical, FlixySpacing.tight)
.listRowBackground(Color.flixySurface)
.swipeActions(edge: .trailing) {
Button("action.delete", role: .destructive, action: onRemove)
}
}
@ViewBuilder
private var action: some View {
switch item.state {
case .downloading:
Button {
Task { await downloads.pause(item) }
} label: {
Image(systemName: "pause.circle").font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text("downloads.pause"))
case .paused, .failed:
Button {
Task { await downloads.resume(item) }
} label: {
Image(systemName: "arrow.clockwise.circle").font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text("downloads.resume"))
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("downloads.available"))
case .queued, .waiting:
Image(systemName: "clock")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("downloads.queued"))
}
}
private var progress: some View {
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
// Download.
if let value = item.progress {
ProgressView(value: value).tint(accent)
} else {
ProgressView().progressViewStyle(.linear).tint(accent)
}
Text(statusLine)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
private var statusLine: String {
switch item.state {
case .downloading where item.totalBytes > 0:
"\(item.receivedBytes.formatted(.byteCount(style: .file))) / \(item.totalBytes.formatted(.byteCount(style: .file)))"
case .downloading:
item.receivedBytes.formatted(.byteCount(style: .file))
case .paused:
String(localized: "downloads.paused")
case .queued, .waiting:
String(localized: "downloads.queued")
case .failed:
String(localized: "downloads.failed")
case .completed:
item.totalBytes.formatted(.byteCount(style: .file))
}
}
}
/// Der Hinweis über der Liste, wenn nichts läuft und nichts kaputt ist.
private struct HoldNotice: View {
let hold: TransferHold
@Environment(\.flixyAccent) private var accent
var body: some View {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
.font(.title3)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
Text(hold == .offline
? "downloads.hold.offline.description"
: "downloads.hold.wifi.description")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
}
.padding(.vertical, FlixySpacing.tight)
.listRowBackground(Color.flixySurface)
.accessibilityElement(children: .combine)
}
}