Drei Fehler, alle im eigenen Code. Die Sendezeiten stehen beim Programmanbieter im Medien-Block, nicht am Eintrag darüber. Ich habe sie oben gesucht, nicht gefunden und jede Sendung verworfen — übrig blieb „Kein Programm verfügbar" auf jedem Sender, und ohne Sendungen fiel auch die Senderliste auf die Tunerliste zurück. Die Senderspalte im Raster fehlte ganz. Die Zeitachse ist mehrere tausend Punkte breit, und der HStack hat den Nachbarn dafür auf null gedrückt. Jetzt hat die Spalte Vorrang, und die Achse liegt als Auflage über einer durchsichtigen Fläche — eine Auflage zählt bei der Größenrechnung nicht mit. Der Abstand der Achsenbeschriftung gehört in die Spaltenbreite hinein statt daneben, sonst steht die ganze Achse acht Punkte neben dem Programm darunter. Und das Einschalten: die Adresse wurde berechnet und verworfen, dann bekam der Player eine Senderkennung, zu der er einen Film in der Mediathek suchte. Den gibt es nicht. Jetzt reicht die Anfrage die fertige Adresse durch, der Player spielt sie ohne Umweg, und meldet weder Fortschritt noch sucht er Metadaten. Scheitert es doch, steht ab jetzt eine Meldung da statt gar nichts. Geprüft wurde das Raster diesmal mit den Augen: ein Gerüst im Simulator zeichnet es mit erfundenen Sendern. Dafür bekommt es die Bildadresse als Abschluss übergeben, statt sie aus der Sitzung zu lesen. Dazu: Downloads zeigen jetzt an, wenn sie auf WLAN warten. Die Hintergrund-Sitzung wartet in dem Fall von sich aus — kein Fehler, nur Stillstand, und das sah bisher aus wie ein hängender Download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
11 KiB
Swift
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.request = 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)
|
|
}
|
|
}
|