Flixy heißt ab hier Halo

Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+320
View File
@@ -0,0 +1,320 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import MediaKit
/// 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
/// Ob die Dateiangaben erst noch geholt werden müssen.
///
/// **Für Zeilen, die nur eine Zusammenfassung tragen.** Die Folgenliste
/// einer Staffel kommt ohne Fassungen und ohne Dateigrößen — mit so einem
/// Titel wäre die Qualitätsliste leer und die erwartete Größe null, was
/// die Prüfung auf freien Speicherplatz stillschweigend überspringt.
/// `DownloadService.downloadAll` holt jede Folge aus genau diesem Grund
/// einzeln nach.
///
/// Geholt wird erst beim ersten Tipp: zwanzig Zeilen beim Erscheinen
/// nachzuladen wären zwanzig Anfragen für einen Knopf, den man meistens
/// nicht drückt.
var loadsDetails: Bool = false
@Environment(HaloSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent
@State private var refusal: DownloadRefusal?
@State private var showsQualityChoice = false
/// Der nachgeholte Titel — gilt ab dann statt `item`.
@State private var detailed: MediaItem?
@State private var isLoadingDetails = false
/// Die Stufe steht fest, die Tonspur fehlt noch.
///
/// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.**
/// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche,
/// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit
/// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg
/// zurück.
@State private var pendingQuality: DownloadQuality?
private var existing: DownloadItem? {
downloads.items.first { $0.ratingKey == item.id }
}
/// Der Titel, aus dem Fassungen und Größen gelesen werden.
///
/// Die Kennung ist in beiden dieselbe — nur die Dateiangaben fehlen dem
/// einen.
private var source: MediaItem { detailed ?? item }
var body: some View {
Button {
act()
} label: {
Group {
if isLoadingDetails {
ProgressView().controlSize(.small)
} else {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.font(.haloCardTitle)
}
}
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
.frame(width: 22, height: 22)
.padding(HaloSpacing.snug)
}
.disabled(isLoadingDetails)
.buttonStyle(.bordered)
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
.accessibilityLabel(Text(label))
.confirmationDialog(
Text("downloads.quality.title"),
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
ForEach(choice.available) { quality in
Button(choice.label(for: quality)) { chose(quality) }
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.quality.hint")
}
.confirmationDialog(
Text("downloads.audio.title"),
isPresented: Binding(
get: { pendingQuality != nil },
set: { if !$0 { pendingQuality = nil } }
),
titleVisibility: .visible
) {
ForEach(audioStreams) { stream in
Button(audioLabel(stream)) {
if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) }
}
}
Button("action.cancel", role: .cancel) {}
} message: {
Text("downloads.audio.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: continues ? "play.circle" : "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: continues ? "downloads.resume" : "action.retry"
case nil: "downloads.download"
}
}
/// Ob ein Tipp dort weitermacht, wo es aufgehört hat.
///
/// Ein gescheiterter Download hat seinen Wiederaufnahme-Datensatz nur,
/// wenn ihn das Netz unterbrochen hat — hat der Server abgelehnt, fängt
/// es von vorn an, und dann heißt der Knopf auch so.
private var continues: Bool { existing?.resumeData != nil }
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.
guard loadsDetails, detailed == nil else {
showsQualityChoice = true
return
}
Task { await loadDetailsThenAsk() }
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) }
}
}
/// Holt die Dateiangaben nach und fragt dann nach der Qualität.
///
/// Scheitert das Nachholen, wird trotzdem gefragt — dann steht nur
/// „Original" zur Wahl, und die Ablehnung kommt mit einer Begründung aus
/// `DownloadService`. Das ist besser als ein Knopf, der nach einem Tipp
/// kommentarlos nichts tut.
private func loadDetailsThenAsk() async {
isLoadingDetails = true
detailed = try? await session.backend?.item(id: item.id)
isLoadingDetails = false
showsQualityChoice = true
}
private var service: DownloadService {
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
}
private var audioStreams: [MediaStream] {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
return media?.files.first?.audioStreams ?? []
}
/// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst.
private var recommended: MediaStream? {
let media = source.versions.indices.contains(mediaIndex)
? source.versions[mediaIndex] : source.preferredVersion
guard let part = media?.files.first else { return nil }
return service.preferredAudioStream(in: part, of: source)
}
private func audioLabel(_ stream: MediaStream) -> String {
stream == recommended
? String(localized: "downloads.audio.recommended \(stream.name)")
: stream.name
}
/// Nach der Stufe: entweder fragen oder einreihen.
private func chose(_ quality: DownloadQuality) {
if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) {
pendingQuality = quality
} else {
enqueue(quality: quality, audioStream: nil)
}
}
private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) {
pendingQuality = nil
do {
_ = try service.download(
source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream
)
} catch {
refusal = error
}
}
/// Was zur Wahl steht — siehe `DownloadQualityChoice`.
private var choice: DownloadQualityChoice {
DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend)
}
}
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(HaloSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) 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.haloTextSecondary)
.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, languages: languages
)
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)))")
}
}
}
@@ -0,0 +1,67 @@
import Foundation
import HaloDownloads
import MediaKit
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
///
/// **Als eigener Typ, weil es die Frage inzwischen an zwei Stellen gibt:** am
/// Knopf einer einzelnen Folge und an der Leiste, mit der man mehrere auf
/// einmal lädt. Dieselbe Entscheidung zweimal zu schreiben hiesse, dass sie
/// sich auseinanderentwickelt — und die erste Kopie wäre die mit dem Fehler.
///
/// Kein `View`: hier steht keine Zeile SwiftUI, nur die Frage, was angeboten
/// werden darf und wie es beschriftet ist.
struct DownloadQualityChoice {
let item: MediaItem
var mediaIndex: Int = 0
let backend: (any MediaBackend)?
/// Nur Stufen, die unter der Auflösung der Datei liegen.
///
/// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt.
/// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die
/// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage.
var available: [DownloadQuality] {
let alle = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
guard allowsCompressed else { return [.original] }
return alle
}
/// 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.
func label(for 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)")
}
private var currentMedia: MediaVersion? {
item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion
}
private var allowsCompressed: Bool {
backend?.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: 8_000,
resolution: nil
) != nil
}
}
+414
View File
@@ -0,0 +1,414 @@
import SwiftUI
import HaloDesign
import HaloDownloads
import HaloPlayback
/// 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(\.haloAccent) 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.haloCanvas)
.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: .haloTrailing) { AccountToolbarButton() }
}
}
.haloInlineTitle()
// 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.haloSurface)
.haloRevealsTopChrome()
}
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(\.haloAccent) private var accent
var body: some View {
@Bindable var downloads = downloads
Section {
HStack {
Text("downloads.used")
.foregroundStyle(Color.haloTextSecondary)
Spacer()
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
.foregroundStyle(Color.haloTextPrimary)
}
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(\.haloAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
HStack(spacing: HaloSpacing.regular) {
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = item.subtitle {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.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(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
}
if let message = item.failureMessage {
Text(message)
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
.lineLimit(2)
}
}
.padding(.vertical, HaloSpacing.tight)
.listRowBackground(Color.haloSurface)
.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:
// **Fortsetzen und erneut versuchen sind nicht dasselbe**, und
// der Knopf hat beides gleich genannt. „Fortsetzen" stand auch
// dort, wo es nichts fortzusetzen gab — eine Zusage, die der Code
// nicht halten konnte: ein gescheiterter Download fing wieder bei
// null an. Jetzt entscheidet der Wiederaufnahme-Datensatz, und er
// entsteht genau dann, wenn eine Übertragung unterbrochen wurde.
Button {
Task { await downloads.resume(item) }
} label: {
Image(systemName: continues ? "play.circle" : "arrow.clockwise.circle")
.font(.title3)
}
.buttonStyle(.plain)
.foregroundStyle(accent)
.accessibilityLabel(Text(continues ? "downloads.resume" : "action.retry"))
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("downloads.available"))
case .queued, .waiting:
Image(systemName: "clock")
.foregroundStyle(Color.haloTextMuted)
.accessibilityLabel(Text("downloads.queued"))
}
}
private var progress: some View {
VStack(alignment: .leading, spacing: HaloSpacing.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)
}
// **Links die Zeile, rechts die Prozente — und beides steht
// still.**
//
// Vorher war es eine einzige linksbündige Zeile, und sie zappelte
// bei jeder Aktualisierung: in einer Proportionalschrift ist die
// 1 schmal und die 8 breit, also verschob „181,2 → 190,1" alles
// dahinter. Zweimal je Sekunde.
//
// Drei Dinge halten es jetzt ruhig: Ziffern in fester Breite, ein
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
// Prozente am rechten Rand — die wachsen nach innen und schieben
// nichts.
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) {
Text(statusLine)
.lineLimit(1)
Spacer(minLength: HaloSpacing.snug)
if let percentage {
Text(percentage)
}
}
.font(.haloCaption)
.monospacedDigit()
.foregroundStyle(Color.haloTextMuted)
}
}
/// Der Fortschritt in Prozent — `nil`, solange die Gesamtgröße fehlt.
///
/// Die Zahl, nach der man eigentlich sieht. „181,2 MB von 890,2 MB" sagt
/// dasselbe, aber man muss es ausrechnen.
private var percentage: String? {
guard item.state != .completed, let anteil = item.progress else { return nil }
return anteil.formatted(.percent.precision(.fractionLength(0)))
}
/// Ob ein Antippen dort weitermacht, wo es aufgehört hat.
///
/// Ein angehaltener Download hat seinen Wiederaufnahme-Datensatz immer;
/// ein gescheiterter nur, wenn ihn das Netz unterbrochen hat und nicht der
/// Server abgelehnt.
private var continues: Bool {
item.state == .paused || item.resumeData != nil
}
/// Eine Byte-Zahl mit **fester** Nachkommastelle.
///
/// **Der letzte Rest des Zappelns.** `.byteCount` lässt die Null hinter
/// dem Komma weg: aus „186,0 MB" wird „186 MB", und die Zeile ist zwei
/// Zeichen kürzer. Gemessen an sechs aufeinanderfolgenden Sekunden war das
/// die einzige, die noch sprang.
///
/// `usage: .asProvided`, damit die Einheit bleibt, die hier gewählt wurde
/// — sonst rechnet die Formatierung eigenmächtig um.
private func size(_ value: Int64) -> String {
let (menge, einheit): (Double, UnitInformationStorage) = switch value {
case 1_000_000_000...: (Double(value) / 1_000_000_000, .gigabytes)
case 1_000_000...: (Double(value) / 1_000_000, .megabytes)
case 1_000...: (Double(value) / 1_000, .kilobytes)
default: (Double(value), .bytes)
}
return Measurement(value: menge, unit: einheit).formatted(
.measurement(
width: .abbreviated,
usage: .asProvided,
numberFormatStyle: .number.precision(
.fractionLength(einheit == .bytes ? 0 : 1)
)
)
)
}
/// Wie schnell es gerade geht — `nil`, solange nichts gemessen ist.
private var speed: String? {
guard let perSecond = downloads.rate(forRatingKey: item.ratingKey)?.bytesPerSecond,
perSecond > 0
else { return nil }
return String(localized: "downloads.perSecond \(size(Int64(perSecond)))")
}
/// Wie lange es bei diesem Tempo noch dauert.
private var remaining: String? {
guard let rate = downloads.rate(forRatingKey: item.ratingKey),
let rest = rate.remaining(received: item.receivedBytes, total: item.totalBytes)
else { return nil }
// Unter einer Minute keine Zahl: „noch 0 Min" wäre falsch und „noch
// 43 Sek" genauer, als die Schätzung ist.
guard rest >= .seconds(60) else {
return String(localized: "downloads.remaining.underMinute")
}
return String(localized: "downloads.remaining \(rest.runtimeLabel)")
}
private var statusLine: String {
switch item.state {
case .downloading where item.totalBytes > 0:
// **Jede Zahl in ihrem eigenen Mass.** Beide auf das des Ziels zu
// zwingen war der naheliegende Weg gegen den Einheitenwechsel —
// und er machte den Anfang unlesbar: bei einem Ziel von 2 GB
// stand dort „0,03 GB / 2 GB". Gegen das Zappeln hilft die feste
// Ziffernbreite; der Wechsel von MB auf GB kommt einmal je
// Download und ist der kleinere Preis.
["\(size(item.receivedBytes)) / \(size(item.totalBytes))", speed, remaining]
.compactMap(\.self).joined(separator: " · ")
case .downloading:
// Ohne bekannte Gesamtgröße gibt es keine Restzeit — das Tempo
// aber schon, und es ist genau dort die einzige Auskunft darüber,
// ob überhaupt etwas durchgeht.
[size(item.receivedBytes), speed].compactMap(\.self).joined(separator: " · ")
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(\.haloAccent) private var accent
var body: some View {
HStack(alignment: .top, spacing: HaloSpacing.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(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
Text(hold == .offline
? "downloads.hold.offline.description"
: "downloads.hold.wifi.description")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
.padding(.vertical, HaloSpacing.tight)
.listRowBackground(Color.haloSurface)
.accessibilityElement(children: .combine)
}
}