„Automatisch" misst jetzt, statt die Einstellung durchzureichen

Bisher hieß `PlayerQuality.automatic` wörtlich: nimm die Zahl, die für
WLAN oder Mobilfunk hinterlegt ist. Keine Messung, kein Nachregeln — die
Entscheidung fiel einmal beim Start und galt bis zum Ende.

`StreamHealth` beurteilt den Strom jetzt fortlaufend aus Proben im
Sekundentakt. Ein reiner Typ: kein AVFoundation, keine Uhr, kein Netz.
Damit lässt sich eine schlechte Zugfahrt als Zahlenreihe hinschreiben
und nachsehen, was sie auslöst — zehn Tests tun genau das.

**Welches Signal wann meldet**, ist der eigentliche Inhalt. Der
Pufferverlauf ist der einzige echte Frühwarner: schrumpft er schneller,
als die Zeit vergeht, kommt weniger herein als hinausgeht, und das sieht
man zehn bis zwanzig Sekunden vor dem Rückler. `isLikelyToKeepUp` ist
früh, aber sprunghaft — es zählt erst, wenn es anhält. Der Rücklerzähler
meldet zu spät zum Regeln und bleibt trotzdem der Massstab, an dem sich
hinterher ablesen lässt, ob es besser wurde. Die **gemessene** Bitrate
steht bewusst nicht drin: auf einer kleinen Sprosse holt der Player
wenig, ohne dass die Leitung schlecht wäre.

Ausdrücklich kein Bandbreitentest. Der kostet Daten, ist beim Vorliegen
veraltet und misst die Vergangenheit. Der Pufferverlauf ist die bessere
Messung, weil er die einzige Größe misst, auf die es ankommt: lädt es
schneller, als es läuft.

Gehandelt wird über `preferredPeakBitRate` — der billige Hebel, der ohne
Neuaufbau wirkt. Gezielt wird dabei auf eine Sprosse, **die es in
`AVAsset.variants` wirklich gibt**: ein Wert zwischen zweien wählt
dieselbe wie die untere und machte jede Anzeige darüber zur Lüge.
Absichtlich unsymmetrisch — runter sofort, hinauf frühestens nach einer
Minute Ruhe.

Und getrennt von `quality`: die Automatik ruft **nicht** `select(quality:)`.
Die Methode schreibt `rememberedQuality` fort und setzte eine manuelle
Stufe — womit die Automatik sich selbst abschaltete und eine schlechte
Zugfahrt dauerhaft das Wohnzimmer verschlechterte.

Hat die Playlist nur eine Stufe, greift der Hebel ins Leere. Das ist kein
Fehler, sondern der Fall, für den die Sicherheitsleine gebaut wird — bis
dahin steht er wenigstens im Protokoll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-27 15:40:48 +02:00
co-authored by Claude Opus 5
parent cdff53fa0d
commit 52d6ebba43
5 changed files with 474 additions and 0 deletions
+134
View File
@@ -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<Void, Never>?
/// 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)
}