Files
Flaro/Flixy/Views/Downloads/DownloadsView.swift
T
ScarriffleandClaude Opus 5 7f380e5f4d Die Bibliothek beginnt jetzt in einer Mediathek
Vorher stand dort eine Liste aller Mediatheken: auf „Bibliothek" tippen,
dann auf „Filme", dann endlich ein Raster. In eine andere wechseln hiess
zurück und wieder hinein. Der Zwischenbildschirm hat nie etwas erzählt,
was eine Leiste nicht auch zeigen kann — er war ein Platzproblem, kein
Entwurf.

Jetzt steht man sofort in einer Mediathek, und darüber der unteren Leiste
liegt eine zweite mit den Mediatheken und Live-TV. Auf dem iPad und am Mac
ändert sich nichts: dort steht jede Mediathek einzeln in der Seitenleiste,
auf dem Fernseher nebeneinander in der oberen Leiste — es ist dieselbe
Idee in der Form, die ein Telefon hergibt.

**Warum eine zweite Leiste und nicht Plätze in der unteren.** Die hat
fünf, und alles darüber schiebt das System in ein „Mehr"-Verzeichnis,
dessen Einträge zwei Navigationsleisten übereinander tragen. Genau daran
ist der Zwischenbildschirm seinerzeit entstanden. Eine selbst gezeichnete
Leiste kennt diese Grenze nicht — und sie sieht aus wie die Insel auf dem
iPad, weil sie dieselbe Fläche benutzt: `FlixySidebar.surface(in:)` ist
ein Bauteil, kein nachgebautes Aussehen, und wird jetzt zum dritten Mal
gerufen.

Als Sicherheitsrand und nicht als Überzug, damit die Liste über der
Leiste endet statt dahinter zu verschwinden. Bei mehr als vier
Mediatheken wird sie waagerecht scrollbar.

**Das Monogramm steht jetzt in der Werkzeugleiste**, am rechten Ende, auf
derselben Höhe wie Suche, Filter und Ansicht — die rücken dafür nach
links. Als Überzug lag es über dem Inhalt, auf einem Raster also über
einem Plakat. Dass es dabei an den Anfang der Reihe rutschte, hat einen
Grund, der im Quelltext steht: SwiftUI reiht Werkzeugleisten von aussen
nach innen, ein von aussen angehängter Eintrag steht deshalb **vor** den
übrigen. Wo in der Reihe etwas steht, kann nur der entscheiden, der die
Reihe schreibt. Auf der Startseite bleibt es ein Überzug — dort gibt es
keine Navigationsleiste.

Dazu: die Zuordnung Mediathekenart → Symbol stand dreimal im Projekt, eine
davon falsch (jede Mediathek bekam „film", auch eine mit Serien). Jetzt
einmal, neben den übrigen Ableitungen aus den Modellen.

Geprüft im Simulator: „Bibliothek" öffnet sofort das Raster, die Leiste
wechselt zwischen Filme, Serien, Gemischtes und Live-TV, und das
Monogramm steht ganz rechts in der Werkzeugleiste.

iOS und tvOS bauen, 469 Tests grün.

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

311 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
@Environment(\.horizontalSizeClass) private var sizeClass
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"))
.toolbar {
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
// mehr, und dies ist der Weg dorthin.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
}
}
.flixyInlineTitle()
// Keine Rückfrage beim Entfernen.
//
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
// aber nur die heruntergeladene Kopie: der Titel bleibt auf dem Server
// und ist mit einem Tippen wieder da. Wer nach links wischt und auf
// „Löschen" tippt, hat zweimal gesagt, was er will — ein drittes Mal
// zu fragen ist keine Vorsicht, sondern Aufhalten.
}
/// 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: { Task { await downloads.cancel(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: { Task { await downloads.cancel(item) } })
.contentShape(.rect)
.onTapGesture { play(item) }
}
}
}
}
.scrollContentBackground(.hidden)
.listRowBackground(Color.flixySurface)
.flixyRevealsTopChrome()
}
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)
}
}