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
@@ -0,0 +1,48 @@
import Foundation
// `os(iOS)` statt `canImport(ActivityKit)`: der Import gelingt auch auf
// macOS, die Typen darin sind dort aber als nicht verfügbar markiert. Eine
// Prüfung auf den Import allein übersetzt deshalb nicht.
// Catalyst ausdrücklich ausgenommen: dort gilt `os(iOS)`, aber ActivityKit
// gibt es nicht — die Typen sind zwar da und jeder Aufruf ist als nicht
// verfügbar markiert.
#if os(iOS) && !targetEnvironment(macCatalyst)
import ActivityKit
/// Was im Sperrbildschirm und in der Dynamic Island steht.
///
/// Liegt in HaloDownloads, weil App und Widget-Erweiterung denselben Typ
/// brauchen — zwei getrennte Kopien wären die klassische Quelle für eine Live
/// Activity, die sich nicht mehr aktualisieren lässt: ActivityKit gleicht die
/// Typen über ihren vollen Namen ab.
public struct DownloadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
/// Der Titel, der gerade lädt.
public var title: String
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
public var progress: Double?
public var receivedBytes: Int64
public var totalBytes: Int64
/// Wie viele weitere in der Warteschlange stehen.
public var remainingCount: Int
public init(
title: String,
progress: Double?,
receivedBytes: Int64,
totalBytes: Int64,
remainingCount: Int
) {
self.title = title
self.progress = progress
self.receivedBytes = receivedBytes
self.totalBytes = totalBytes
self.remainingCount = remainingCount
}
}
/// Bleibt über die Laufzeit gleich. Hier leer: welcher Titel lädt, wechselt
/// im Laufe einer Staffel und gehört deshalb in den Zustand.
public init() {}
}
#endif
@@ -0,0 +1,174 @@
import Foundation
import SwiftData
/// Ein heruntergeladener oder herunterzuladender Titel.
///
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
@Model
public final class DownloadItem {
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
/// soll nicht zweimal in der Warteschlange stehen.
@Attribute(.unique) public var ratingKey: String
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
public var stateRaw: String
public var title: String
public var subtitle: String?
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
public var seriesKey: String?
public var seriesTitle: String?
public var seasonKey: String?
public var seasonIndex: Int?
public var episodeIndex: Int?
public var totalBytes: Int64
public var receivedBytes: Int64
/// Ob `totalBytes` die **genaue** Größe ist oder eine Schätzung.
///
/// **Daran hängt, wann eine Datei als abgerissen gilt.** Beim Original
/// nennt der Server die Dateigröße in den Metadaten — da ist jedes
/// fehlende Byte ein Abriss. Bei den umgerechneten Stufen rechnet die App
/// die Größe aus Bitrate und Dauer hoch, und die weicht von Haus aus ab;
/// dort wäre dieselbe Strenge falsch.
///
/// Ohne diese Unterscheidung galt für beides die Hälfte-Regel — und eine
/// bei sechzig Prozent abgerissene Datei bekam einen Haken.
public var expectedIsExact: Bool = false
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
/// absoluter Pfad zeigt danach ins Leere.
public var localFileName: String?
public var artworkFileName: String?
public var addedAt: Date
public var completedAt: Date?
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
public var watchedAt: Date?
public var failureMessage: String?
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
/// App-Container und nicht in den Voreinstellungen.
public var sourceURLString: String
public var durationMilliseconds: Int?
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
public var offlineMediaData: Data?
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
public var resumeData: Data?
public init(
ratingKey: String,
title: String,
subtitle: String? = nil,
sourceURLString: String,
state: DownloadState = .queued,
totalBytes: Int64 = 0,
seriesKey: String? = nil,
seriesTitle: String? = nil,
seasonKey: String? = nil,
seasonIndex: Int? = nil,
episodeIndex: Int? = nil,
durationMilliseconds: Int? = nil,
offlineMediaData: Data? = nil,
expectedIsExact: Bool = false
) {
self.ratingKey = ratingKey
self.title = title
self.subtitle = subtitle
self.sourceURLString = sourceURLString
self.stateRaw = state.rawValue
self.totalBytes = totalBytes
self.receivedBytes = 0
self.seriesKey = seriesKey
self.seriesTitle = seriesTitle
self.seasonKey = seasonKey
self.seasonIndex = seasonIndex
self.episodeIndex = episodeIndex
self.durationMilliseconds = durationMilliseconds
self.offlineMediaData = offlineMediaData
self.expectedIsExact = expectedIsExact
self.addedAt = Date()
}
public var state: DownloadState {
get { DownloadState(rawValue: stateRaw) ?? .failed }
set { stateRaw = newValue.rawValue }
}
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
///
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
/// die ehrlichere Anzeige.
public var progress: Double? {
guard totalBytes > 0 else { return nil }
return min(Double(receivedBytes) / Double(totalBytes), 1)
}
public var isComplete: Bool { state == .completed }
/// Wie viel Platz dieser Eintrag gerade belegt.
public var occupiedBytes: Int64 {
state.occupiesStorage ? max(receivedBytes, 0) : 0
}
}
/// Was der Player offline über eine Datei wissen muss.
///
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
/// um zwischen die Engines zu wählen.
public struct OfflineMedia: Sendable, Codable, Equatable {
public var container: String?
public var videoCodec: String?
public var audioCodec: String?
public var videoResolution: String?
public var bitrate: Int?
public var audioLanguages: [String]
public var subtitleLanguages: [String]
/// Intro und Abspann, in Millisekunden.
public var markers: [OfflineMarker]
public init(
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
bitrate: Int? = nil,
audioLanguages: [String] = [],
subtitleLanguages: [String] = [],
markers: [OfflineMarker] = []
) {
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.bitrate = bitrate
self.audioLanguages = audioLanguages
self.subtitleLanguages = subtitleLanguages
self.markers = markers
}
}
public struct OfflineMarker: Sendable, Codable, Equatable {
public var type: String
public var startMilliseconds: Int
public var endMilliseconds: Int
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
self.type = type
self.startMilliseconds = startMilliseconds
self.endMilliseconds = endMilliseconds
}
}