diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 4244c22..d9eb368 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -190,6 +190,35 @@ final class PlayerModel { /// gedrosselten Leitung ermittelt. private static let forwardBuffer: Duration = .seconds(25) + /// Wie oft der Zustand des Stroms geprüft wird. + /// + /// Einmal je Sekunde: fein genug, dass ein schrumpfender Puffer auffällt, + /// bevor er leer ist, und grob genug, dass es nichts kostet. + private static let healthInterval: Duration = .seconds(1) + + /// Wie lange nach einem Hinaufschalten Ruhe herrscht. + /// + /// Hinauf wird nur zögerlich gegangen: ein falscher Schritt nach unten + /// kostet Schärfe, ein falscher nach oben einen Rückler. + private static let stepUpCooldown: Duration = .seconds(60) + + /// Der Zustand des Stroms, fortlaufend beurteilt. + @ObservationIgnored private var health = StreamHealth() + @ObservationIgnored private var healthTask: Task? + /// Wann zuletzt eine Stufe höher gegangen wurde. + @ObservationIgnored private var lastStepUp: Duration? + @ObservationIgnored private var hasNotedMissingLadder = false + + /// Die Obergrenze, die die Automatik gerade setzt — in Kilobit. + /// + /// **Getrennt von `quality`, und das ist wesentlich.** Würde die + /// Automatik `select(quality:)` rufen, schriebe sie `rememberedQuality` + /// fort und setzte eine manuelle Stufe — womit sie sich selbst + /// abschaltete und eine schlechte Zugfahrt dauerhaft das Wohnzimmer + /// verschlechterte. `quality` bleibt deshalb `.automatic`; nur dieser + /// Wert wandert. + private(set) var automaticCeiling: Int? + /// Das Buch über die Rückler dieser Wiedergabe. @ObservationIgnored private var stallLedger = StallLedger() /// Seit wann tatsächlich gespielt wird — der Nullpunkt für das Buch. @@ -467,6 +496,9 @@ final class PlayerModel { diagnostics.note("Engine gestartet") // Weder offline noch live gibt es einen Fortschritt zu melden. if !isOffline, !isLive { startReporting() } + // Den Zustand des Stroms dagegen auch bei Live beobachten: dort + // hakt es am ehesten, und der billige Hebel kostet nichts. + startHealthWatch() } catch { diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)") phase = .failed(message: String(localized: "player.loadFailed")) @@ -530,6 +562,102 @@ final class PlayerModel { // MARK: - Steuerung + // MARK: - Die Qualität selbst nachregeln + + /// Beobachtet den Strom und passt die Obergrenze an. + /// + /// Läuft nur bei „automatisch". Eine ausdrücklich gewählte Stufe ist eine + /// Ansage und wird nicht überstimmt — `automatic` ist die Erlaubnis + /// einzugreifen, alles andere nicht. + private func startHealthWatch() { + healthTask?.cancel() + guard quality == .automatic, !isOffline else { return } + + healthTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: Self.healthInterval) + guard !Task.isCancelled, let self else { return } + self.sampleStream() + } + } + } + + /// Eine Probe nehmen und daraus handeln. + private func sampleStream() { + guard let engine, let startedAt = playbackStartedAt else { return } + guard let statistics = engine.statistics else { return } + + let now = ContinuousClock.now - startedAt + let urteil = health.record(StreamSample( + at: now, + bufferedAhead: statistics.bufferedAhead, + isLikelyToKeepUp: statistics.isLikelyToKeepUp, + stallCount: statistics.stallCount, + indicatedBitrate: statistics.indicatedBitrate + )) + + // **Ohne Leiter greift der billige Hebel ins Leere.** AVPlayer spielt + // die eine Stufe auch dann, wenn sie über der Grenze liegt. Das ist + // kein Fehler, sondern der Fall, für den es die Sicherheitsleine gibt + // — und bis die steht, gehört er wenigstens ins Protokoll. + guard statistics.hasLadder else { + noteMissingLadder(urteil) + return + } + + adjustCeiling(for: urteil, statistics: statistics, at: now) + } + + /// Setzt die Obergrenze eine Sprosse tiefer oder höher. + private func adjustCeiling( + for condition: StreamCondition, + statistics: PlaybackStatistics, + at now: Duration + ) { + let sprossen = statistics.variantBitrates + guard let unterste = sprossen.first, let oberste = sprossen.last else { return } + let aktuell = automaticCeiling ?? statistics.indicatedBitrate ?? oberste + + let ziel: Int? + switch condition { + case .failing: + ziel = unterste + case .strained: + // **Auf eine Sprosse, die es wirklich gibt.** Ein Wert zwischen + // zweien wählt dieselbe wie die untere und macht jede Anzeige + // darüber zur Lüge. + ziel = statistics.rung(below: aktuell) ?? unterste + case .healthy: + // Hinauf nur zögerlich, und nur nach einer Ruhefrist. + guard let lastStepUp, now - lastStepUp >= Self.stepUpCooldown else { + if lastStepUp == nil { self.lastStepUp = now } + return + } + guard let höher = sprossen.first(where: { $0 > aktuell }) else { return } + ziel = höher + } + + guard let ziel, ziel != automaticCeiling else { return } + + automaticCeiling = ziel + if condition == .healthy { lastStepUp = now } + // Die oberste Sprosse ist keine Grenze, sondern das Fehlen einer. + engine?.setPeakBitrate(ziel >= oberste ? nil : ziel) + + diagnostics.note(String( + format: "Automatik: %@ → höchstens %d kbit", + String(describing: condition), + ziel + )) + } + + /// Protokolliert einmalig, dass es nichts zum Umschalten gibt. + private func noteMissingLadder(_ condition: StreamCondition) { + guard condition != .healthy, !hasNotedMissingLadder else { return } + hasNotedMissingLadder = true + diagnostics.note("Es hakt, aber die Playlist hat nur eine Stufe — nichts zum Ausweichen") + } + /// Führt Buch über das Stocken — die **zweite** Uhr. /// /// Der Wachhund darunter ist ein Totmann: vierzig Sekunden am Stück ohne @@ -699,6 +827,12 @@ final class PlayerModel { playbackStartedAt = nil stallBegan = nil isConnectionStruggling = false + healthTask?.cancel() + healthTask = nil + health.removeAll() + automaticCeiling = nil + lastStepUp = nil + hasNotedMissingLadder = false NowPlaying.shared.release(for: self) } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StreamHealth.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StreamHealth.swift new file mode 100644 index 0000000..df3f4b1 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StreamHealth.swift @@ -0,0 +1,175 @@ +import Foundation + +/// Eine Probe vom laufenden Strom. +/// +/// 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 Strom 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 Strom 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) + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index e2a2806..56d8905 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -201,6 +201,13 @@ public final class AVPlayerEngine: VideoPlayerEngine { continuation?.yield(.timeChanged(time)) } + public func setPeakBitrate(_ kilobits: Int?) { + guard let item = player.currentItem else { return } + // Null heißt bei AVFoundation „keine Grenze". Kein Sonderfall, nur + // eine andere Schreibweise für dasselbe. + item.preferredPeakBitRate = kilobits.map { Double($0) * 1_000 } ?? 0 + } + public func setRate(_ rate: Double) { player.rate = Float(rate) } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift index 95d11df..7382813 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift @@ -98,6 +98,20 @@ public protocol VideoPlayerEngine: AnyObject { /// Leitung schlecht ist?" eine Vermutung. var statistics: PlaybackStatistics? { get } + /// Deckelt, welche Sprosse der Player höchstens ziehen darf. + /// + /// **Der billige Hebel.** Er wirkt ohne Neuaufbau: kein Anhalten am + /// Server, keine neue Aushandlung, kein Sprung zurück an die alte Stelle. + /// `nil` hebt die Grenze auf. + /// + /// Zwei Einschränkungen gehören dazugesagt. Der Wert greift erst an der + /// nächsten Schnipselgrenze, also mit einigen Sekunden Verzug — ein Grund + /// mehr, nicht bei jedem Ausschlag daran zu drehen. Und **bei einer + /// Playlist mit einer einzigen Stufe tut er nichts**: der Player spielt + /// sie auch dann, wenn sie über der Grenze liegt. Der Wert ist ein + /// Wunsch, keine Vorschrift. + func setPeakBitrate(_ kilobits: Int?) + /// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen, /// sonst behält besonders VLCKit den Speicher. func teardown() @@ -203,6 +217,10 @@ extension VideoPlayerEngine { /// Engines, die nichts melden, melden nichts — statt jeden zu zwingen. public var statistics: PlaybackStatistics? { nil } + /// Wer keine Leiter hat, kann auch nichts deckeln. Kein Fehler, nur + /// nichts zu tun — bei einer Datei gibt es keine Stufen. + public func setPeakBitrate(_ kilobits: Int?) {} + /// Wer nichts dazu sagt, kann es nicht. public var supportsAirPlay: Bool { false } } diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/StreamHealthTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StreamHealthTests.swift new file mode 100644 index 0000000..297e6e3 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StreamHealthTests.swift @@ -0,0 +1,140 @@ +import Testing +import Foundation +@testable import FlixyPlayback + +@Suite("Zustand des Stroms") +struct StreamHealthTests { + /// Baut eine Folge von Proben im Sekundentakt. + private func lauf( + _ health: inout StreamHealth, + von: Int, + bis: Int, + puffer: (Int) -> Double?, + stalls: (Int) -> Int = { _ in 0 }, + keepUp: (Int) -> Bool? = { _ in true } + ) -> StreamCondition { + var urteil = StreamCondition.healthy + for sekunde in von...bis { + urteil = health.record(StreamSample( + at: .seconds(sekunde), + bufferedAhead: puffer(sekunde).map { .milliseconds(Int($0 * 1000)) }, + isLikelyToKeepUp: keepUp(sekunde), + stallCount: stalls(sekunde) + )) + } + return urteil + } + + @Test("Ein voller, stabiler Puffer ist gesund") + func fullBufferIsHealthy() { + var health = StreamHealth() + let urteil = lauf(&health, von: 0, bis: 60, puffer: { _ in 30 }) + #expect(urteil == .healthy) + } + + /// Der Fall, für den das Ganze gebaut ist: der Puffer schrumpft stetig. + /// Das muss auffallen, **bevor** er leer ist. + @Test("Ein schrumpfender Puffer meldet sich vor dem Rückler") + func shrinkingBufferWarnsEarly() { + var health = StreamHealth() + // Start mit 30 s, dann jede Sekunde eine weniger. + _ = lauf(&health, von: 0, bis: 10, puffer: { _ in 30 }) + let urteil = lauf(&health, von: 11, bis: 36, puffer: { 30 - Double($0 - 10) }) + + #expect(urteil == .strained) + // Und zwar bei noch fünf Sekunden Puffer — nicht bei null. + #expect(health.condition != .failing) + } + + @Test("Ein kleiner, aber stabiler Puffer ist kein Alarm") + func smallStableBufferIsNotAlarming() { + var health = StreamHealth() + let urteil = lauf(&health, von: 0, bis: 30, puffer: { _ in 4 }) + // Klein, aber er hält. Das ist kein Grund, die Stufe zu senken. + #expect(urteil != .strained) + #expect(urteil != .failing) + } + + @Test("Zwei Rückler in einer Minute heißen: trägt nicht") + func twoStallsMeanFailing() { + var health = StreamHealth() + let urteil = lauf( + &health, + von: 0, + bis: 40, + puffer: { _ in 10 }, + stalls: { $0 < 10 ? 0 : ($0 < 25 ? 1 : 2) } + ) + #expect(urteil == .failing) + } + + @Test("Ein leerer Puffer heißt sofort: trägt nicht") + func emptyBufferIsFailing() { + var health = StreamHealth() + let urteil = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 }) + #expect(urteil == .failing) + } + + /// „Noch nichts geladen" ist etwas anderes als „Puffer leer". Beim Start + /// darf das nicht als Notlage durchgehen. + @Test("Ohne Messwert kein Alarm") + func unknownBufferIsNotAlarming() { + var health = StreamHealth() + let urteil = lauf(&health, von: 0, bis: 10, puffer: { _ in nil }) + #expect(urteil == .healthy) + } + + /// Der Weg zurück ist absichtlich schmal: schnell runter, langsam rauf. + @Test("Zurück nach gesund braucht eine halbe Minute Ruhe") + func recoveryIsSlow() { + var health = StreamHealth() + _ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 }) + #expect(health.condition == .failing) + + // Puffer sofort wieder voll — aber zu kurz. + let früh = lauf(&health, von: 21, bis: 40, puffer: { _ in 30 }) + #expect(früh != .healthy) + + // Nach einer halben Minute durchgehend gut: erholt. + let spät = lauf(&health, von: 41, bis: 90, puffer: { _ in 30 }) + #expect(spät == .healthy) + } + + /// Ein einzelner Ausschlag von `isLikelyToKeepUp` kommt bei jedem Sprung + /// vor und darf nichts auslösen. + @Test("Ein einzelner Ausschlag löst nichts aus") + func singleKeepUpDipIsIgnored() { + var health = StreamHealth() + let urteil = lauf( + &health, + von: 0, + bis: 30, + puffer: { _ in 25 }, + keepUp: { $0 == 15 ? false : true } + ) + #expect(urteil == .healthy) + } + + @Test("Anhaltende Sorge des Systems zählt") + func sustainedKeepUpFalseCounts() { + var health = StreamHealth() + let urteil = lauf( + &health, + von: 0, + bis: 30, + puffer: { _ in 25 }, + keepUp: { $0 >= 27 ? false : true } + ) + #expect(urteil == .strained) + } + + @Test("Zurücksetzen macht einen frischen Anfang") + func resetStartsOver() { + var health = StreamHealth() + _ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 }) + #expect(health.condition == .failing) + + health.removeAll() + #expect(health.condition == .healthy) + } +}