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>
311 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|