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) } }