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>
176 lines
6.9 KiB
Swift
176 lines
6.9 KiB
Swift
import Foundation
|
|
|
|
/// Eine Probe vom laufenden Stream.
|
|
///
|
|
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
|
|
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
|
|
/// mitten im Film stellt, darf das Urteil nicht kippen.
|
|
public struct StreamSample: Sendable, Equatable {
|
|
public let at: Duration
|
|
/// Wie weit der Puffer vor der Abspielstelle reicht. `nil` heißt
|
|
/// „noch nichts geladen" — etwas anderes als ein leerer Puffer.
|
|
public let bufferedAhead: Duration?
|
|
public let isLikelyToKeepUp: Bool?
|
|
/// Der fortlaufende Zähler, nicht die Zahl seit der letzten Probe.
|
|
public let stallCount: Int
|
|
/// Auf welcher Sprosse gerade gespielt wird, in Kilobit je Sekunde.
|
|
public let indicatedBitrate: Int?
|
|
|
|
public init(
|
|
at: Duration,
|
|
bufferedAhead: Duration? = nil,
|
|
isLikelyToKeepUp: Bool? = nil,
|
|
stallCount: Int = 0,
|
|
indicatedBitrate: Int? = nil
|
|
) {
|
|
self.at = at
|
|
self.bufferedAhead = bufferedAhead
|
|
self.isLikelyToKeepUp = isLikelyToKeepUp
|
|
self.stallCount = stallCount
|
|
self.indicatedBitrate = indicatedBitrate
|
|
}
|
|
}
|
|
|
|
/// Wie es dem Stream geht.
|
|
public enum StreamCondition: Sendable, Equatable {
|
|
/// Trägt. Es darf wieder eine Stufe höher gehen.
|
|
case healthy
|
|
/// Wird knapp. Eine Stufe tiefer, bevor das Bild steht.
|
|
case strained
|
|
/// Trägt nicht. So tief wie möglich.
|
|
case failing
|
|
}
|
|
|
|
/// Beurteilt den Stream aus einer Folge von Proben.
|
|
///
|
|
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
|
|
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
|
|
/// demselben Grund: eine Regelung, die man nur im laufenden Film beobachten
|
|
/// kann, ist nicht zu beurteilen. Hier lässt sich eine schlechte Zugfahrt als
|
|
/// Zahlenreihe hinschreiben und nachsehen, was sie auslöst.
|
|
///
|
|
/// **Welches Signal wann meldet** — das ist der eigentliche Inhalt:
|
|
///
|
|
/// - **Der Pufferverlauf ist der einzige echte Frühwarner.** Schrumpft er
|
|
/// schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht.
|
|
/// Das sieht man zehn bis zwanzig Sekunden vor dem Rückler.
|
|
/// - `isLikelyToKeepUp` ist früh, aber grob: Apple legt es um, wenn es sich
|
|
/// schon sorgt. Als Bestätigung gut, als alleiniger Auslöser zu sprunghaft.
|
|
/// - Der Rücklerzähler meldet **zu spät zum Regeln** — das Bild stand bereits.
|
|
/// Er ist trotzdem unverzichtbar: an ihm misst sich hinterher, ob es besser
|
|
/// wurde.
|
|
/// - Die **gemessene** Bitrate steht hier bewusst nicht. Auf einer kleinen
|
|
/// Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre —
|
|
/// als Kriterium wäre sie irreführend.
|
|
///
|
|
/// **Absichtlich unsymmetrisch: schnell runter, langsam rauf.** Ein falscher
|
|
/// Schritt nach unten kostet Schärfe, ein falscher nach oben einen Rückler.
|
|
public struct StreamHealth: Sendable, Equatable {
|
|
/// Wie viel Puffer als knapp gilt.
|
|
public static let lowBuffer: Duration = .seconds(6)
|
|
/// Ab wann er als reichlich gilt.
|
|
public static let goodBuffer: Duration = .seconds(20)
|
|
|
|
private var samples: [StreamSample] = []
|
|
private(set) public var condition: StreamCondition = .healthy
|
|
/// Seit wann es ununterbrochen gut aussieht. `nil`, sobald es das nicht tut.
|
|
private var healthySince: Duration?
|
|
|
|
public init() {}
|
|
|
|
/// Nimmt eine Probe und gibt das aktuelle Urteil zurück.
|
|
@discardableResult
|
|
public mutating func record(_ sample: StreamSample) -> StreamCondition {
|
|
samples.append(sample)
|
|
samples.removeAll { $0.at < sample.at - .seconds(60) }
|
|
|
|
condition = judge(at: sample)
|
|
return condition
|
|
}
|
|
|
|
public mutating func removeAll() {
|
|
samples.removeAll()
|
|
healthySince = nil
|
|
condition = .healthy
|
|
}
|
|
|
|
// MARK: - Das Urteil
|
|
|
|
private mutating func judge(at now: StreamSample) -> StreamCondition {
|
|
// **Zuerst das Schlimmste.** Zwei Rückler in einer Minute heißen: es
|
|
// war kein Ausrutscher.
|
|
if stalls(within: .seconds(60), at: now) >= 2 {
|
|
healthySince = nil
|
|
return .failing
|
|
}
|
|
// Puffer auf null, während gespielt wird. `nil` zählt nicht — das
|
|
// heißt „noch nichts geladen" und ist der Zustand beim Start.
|
|
if let buffer = now.bufferedAhead, buffer == .zero {
|
|
healthySince = nil
|
|
return .failing
|
|
}
|
|
|
|
if isStrained(at: now) {
|
|
healthySince = nil
|
|
return .strained
|
|
}
|
|
|
|
// **Der Weg zurück ist der schmalste.** Reichlich Puffer allein
|
|
// genügt nicht: er muss eine halbe Minute lang reichlich geblieben
|
|
// sein, und in der letzten Minute darf es nicht gehakt haben. Sonst
|
|
// pendelt die Stufe im Takt der Aussetzer, und das ist schlimmer als
|
|
// eine Stufe zu niedrig zu bleiben.
|
|
guard let buffer = now.bufferedAhead, buffer >= Self.goodBuffer else {
|
|
healthySince = nil
|
|
return condition == .healthy ? .healthy : condition
|
|
}
|
|
|
|
let seit = healthySince ?? now.at
|
|
healthySince = seit
|
|
|
|
let langGenug = now.at - seit >= .seconds(30)
|
|
let ruhig = stalls(within: .seconds(60), at: now) == 0
|
|
guard langGenug, ruhig else {
|
|
return condition == .healthy ? .healthy : condition
|
|
}
|
|
return .healthy
|
|
}
|
|
|
|
private func isStrained(at now: StreamSample) -> Bool {
|
|
// Knapp **und** fallend. Ein kleiner Puffer, der stabil bleibt, ist
|
|
// kein Alarm — er ist nur klein.
|
|
if let buffer = now.bufferedAhead, buffer < Self.lowBuffer, isFalling(at: now) {
|
|
return true
|
|
}
|
|
|
|
// Apples eigene Sorge, aber erst wenn sie anhält: ein einzelner
|
|
// Ausschlag kommt bei jedem Sprung vor.
|
|
let letzte = samples.filter { $0.at >= now.at - .seconds(3) }
|
|
if letzte.count >= 3, letzte.allSatisfy({ $0.isLikelyToKeepUp == false }) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Ob der Puffer über die letzten Sekunden hinweg schrumpft.
|
|
private func isFalling(at now: StreamSample) -> Bool {
|
|
let fenster = samples.filter { $0.at >= now.at - .seconds(5) }
|
|
guard fenster.count >= 3,
|
|
let ältester = fenster.first?.bufferedAhead,
|
|
let jüngster = fenster.last?.bufferedAhead
|
|
else { return false }
|
|
return jüngster < ältester
|
|
}
|
|
|
|
/// Wie viele Rückler im Zeitfenster dazugekommen sind.
|
|
///
|
|
/// Aus der **Differenz** des fortlaufenden Zählers, nicht aus einer
|
|
/// eigenen Zählung: der Zähler kommt vom System und weiß auch von
|
|
/// Rücklern zwischen zwei Proben.
|
|
private func stalls(within window: Duration, at now: StreamSample) -> Int {
|
|
let fenster = samples.filter { $0.at >= now.at - window }
|
|
guard let erster = fenster.first else { return 0 }
|
|
return max(0, now.stallCount - erster.stallCount)
|
|
}
|
|
}
|