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>
415 lines
16 KiB
Swift
415 lines
16 KiB
Swift
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)
|
|
}
|
|
}
|