Der Player weiß jetzt, wie es ihm geht

Bisher gab es über eine laufende Wiedergabe zwei Zahlen — gemessene
Bitrate und verworfene Bilder —, und die standen nur zur Anzeige da. Die
Frage „trägt die Leitung das gerade" war nicht zu beantworten, weder für
den Nutzer noch für die App.

**Die wichtigste neue Zahl ist `variantCount`.** Sie kommt aus
`AVAsset.variants`, also vom Parser des Players, und beantwortet die
Frage, ob es überhaupt etwas zum Umschalten gibt. Steht dort 1, greift
jedes Nachregeln ins Leere — und genau das ist heute der Fall, weil die
App bei beiden Servern eine Playlist mit einer Stufe bestellt.

Dazu der Pufferstand aus `loadedTimeRanges`: der einzige echte
Frühwarner. Schrumpft er schneller, als die Zeit vergeht, kommt weniger
herein als hinausgeht — zehn bis zwanzig Sekunden, bevor das Bild steht.
Alles andere meldet sich erst danach.

**Verworfene Bilder werden jetzt über alle Protokolleinträge summiert.**
Das Zugriffsprotokoll legt bei jedem Stufenwechsel einen neuen an, dessen
Zähler bei null beginnt; wer nur `last` liest, hält eine Wiedergabe für
makellos, die schon zwanzigmal Bilder verworfen hat.

**Der Wachhund war blind für den häufigsten Fall.** Vierzig Sekunden am
Stück ohne Daten brechen ab — aber er setzte sich bei jedem kurzen
Erholen zurück, und zehn Rückler à dreißig Sekunden lösten ihn nie aus.
`StallLedger` zählt daneben mit, wie viel Zeit im gleitenden Fenster
fürs Warten draufging. Es bricht nichts ab, es stellt nur fest — und ist
ein reiner Typ ohne Uhr und ohne AVFoundation, damit sich eine Folge von
Rücklern als Zahlenreihe hinschreiben und das Urteil prüfen lässt.
Sieben Tests, darunter genau der Fall, an dem der alte Wachhund
vorbeisah.

Gezählt wird nur der Weg vom Spielen ins Warten. Das anfängliche Puffern
ist der Start, kein Rückler; würde es mitgezählt, hätte jede Wiedergabe
eine Vorstrafe.

Und zwei Kommentare sind berichtigt. Sie behaupteten, AVPlayer wechsle
die Bitrate von selbst und das sei „die zugesagte automatische
Qualitätsanpassung". Umschalten kann er nur zwischen Varianten, die in
der Liste stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-27 14:59:29 +02:00
co-authored by Claude Opus 5
parent 9c66727aca
commit 903bfe5f63
9 changed files with 680 additions and 70 deletions
@@ -17,6 +17,7 @@ public final class AVPlayerEngine: VideoPlayerEngine {
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
noteStall(from: oldValue, to: state)
continuation?.yield(.stateChanged(state))
}
}
@@ -50,6 +51,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
private var pendingStartTime: Duration = .zero
private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)?
private var stallObserver: (any NSObjectProtocol)?
/// Die Sprossen der Playlist, aufsteigend, in Kilobit je Sekunde.
///
/// Leer, solange nichts geladen ist — und leer bleibt sie auch bei einer
/// Datei, die gar keine Playlist ist. Genau daran unterscheidet
/// `statistics` später „keine Leiter" von „eine Stufe".
private var variantBitrates: [Int] = []
/// Wie oft es gestockt hat, seit diese Quelle läuft.
///
/// Aus `playbackStalledNotification` und **nicht** aus dem
/// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll
/// nur bei Strömen über das Netz. Beides zu addieren zählte doppelt.
private var stallCount = 0
/// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`.
private var accumulatedStall: Duration = .zero
private var bufferingSince: ContinuousClock.Instant?
public init() {
AudioSession.activate()
@@ -59,6 +78,13 @@ public final class AVPlayerEngine: VideoPlayerEngine {
teardownObservers()
state = .loading
// Die Buchführung gehört zur Quelle, nicht zum Motor: ein neuer Titel
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
variantBitrates = []
stallCount = 0
accumulatedStall = .zero
bufferingSince = nil
pendingStartTime = source.startTime
let asset = AVURLAsset(url: source.url, options: [
@@ -100,6 +126,23 @@ public final class AVPlayerEngine: VideoPlayerEngine {
continuation?.yield(.durationResolved(value))
}
guard !Task.isCancelled else { return }
// **Die Leiter.** Hier und nicht vorher: die Frage „wie viele
// Stufen hat diese Playlist" beantwortet der Parser des Players,
// aber sie zu beantworten kostet einen Abruf — und der darf den
// Start so wenig aufhalten wie Dauer und Spuren.
//
// Bei einer Datei kommt eine leere Liste zurück. Das ist keine
// Leiter mit null Sprossen, sondern gar keine Leiter, und genau
// so wird es unten auch gemeldet.
if let variants = try? await asset.load(.variants), !Task.isCancelled {
variantBitrates = variants
.compactMap(\.peakBitRate)
.map { Int(($0 / 1_000).rounded()) }
.filter { $0 > 0 }
.sorted()
}
guard !Task.isCancelled else { return }
// Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem
// laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein
@@ -144,7 +187,8 @@ public final class AVPlayerEngine: VideoPlayerEngine {
/// beschreiben, was vorher lief.
public var statistics: PlaybackStatistics? {
guard let item = player.currentItem else { return nil }
let event = item.accessLog()?.events.last
let events = item.accessLog()?.events ?? []
let event = events.last
func kilobits(_ value: Double?) -> Int? {
guard let value, value > 0 else { return nil }
@@ -157,10 +201,68 @@ public final class AVPlayerEngine: VideoPlayerEngine {
indicatedBitrate: kilobits(event?.indicatedBitrate),
width: size.width > 0 ? Int(size.width) : nil,
height: size.height > 0 ? Int(size.height) : nil,
droppedFrames: event.map { max(0, $0.numberOfDroppedVideoFrames) }
// **Über alle Einträge summiert, nicht nur der letzte.** Das
// Protokoll legt bei jedem Stufenwechsel einen neuen an, und
// dessen Zähler beginnt wieder bei null. Wer nur `last` liest,
// verliert nach jedem Wechsel die ganze Vorgeschichte — und sieht
// eine Wiedergabe, die schon zwanzigmal Bilder verworfen hat, als
// makellos an.
droppedFrames: events.isEmpty
? nil
: events.reduce(0) { $0 + max(0, $1.numberOfDroppedVideoFrames) },
variantCount: variantBitrates.isEmpty ? nil : variantBitrates.count,
variantBitrates: variantBitrates,
bufferedAhead: bufferedAhead(in: item),
isLikelyToKeepUp: item.isPlaybackLikelyToKeepUp,
stallCount: stallCount,
stalledTime: stalledTime
)
}
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// Gibt `nil` zurück, wenn noch gar nichts geladen ist — das ist etwas
/// anderes als ein leerer Puffer und darf nicht als Alarm durchgehen.
/// Liegen dagegen Bereiche vor, in denen die Abspielstelle **nicht**
/// liegt, ist der Puffer an genau dieser Stelle leer, und das ist eine
/// Aussage: null.
private func bufferedAhead(in item: AVPlayerItem) -> Duration? {
let ranges = item.loadedTimeRanges.map(\.timeRangeValue)
guard !ranges.isEmpty else { return nil }
let now = item.currentTime()
guard now.isNumeric else { return nil }
for range in ranges where range.containsTime(now) {
let end = range.end
guard end.isNumeric else { continue }
return .seconds(max(0, (end - now).seconds))
}
return .zero
}
/// Die Wartezeit einschließlich der gerade laufenden.
private var stalledTime: Duration {
guard let bufferingSince else { return accumulatedStall }
return accumulatedStall + (ContinuousClock.now - bufferingSince)
}
/// Buchführung über das Stocken.
///
/// Gezählt wird **nur** der Weg vom Spielen ins Warten. Das anfängliche
/// Puffern einer frisch geladenen Quelle ist kein Rückler — es ist der
/// Start. Würde es mitgezählt, hätte jede Wiedergabe von vornherein eine
/// Vorstrafe, und eine Regel wie „zwei Rückler in einer Minute" löste
/// schon beim Anschauen des ersten Bildes aus.
private func noteStall(from previous: PlaybackState, to current: PlaybackState) {
if current == .buffering {
if previous == .playing { bufferingSince = .now }
} else if let since = bufferingSince {
accumulatedStall += ContinuousClock.now - since
bufferingSince = nil
}
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
@@ -270,6 +372,20 @@ public final class AVPlayerEngine: VideoPlayerEngine {
}
)
// Der ausdrückliche Stocker-Melder des Systems.
//
// Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen
// blieb — beim anfänglichen Puffern schweigt er. Damit ist er der
// ehrlichste Zähler, den es gibt, und der Massstab, an dem sich am
// Ende ablesen lässt, ob es besser wurde.
stallObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.playbackStalledNotification,
object: item,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.stallCount += 1 }
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
@@ -307,6 +423,10 @@ public final class AVPlayerEngine: VideoPlayerEngine {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
if let stallObserver {
NotificationCenter.default.removeObserver(stallObserver)
self.stallObserver = nil
}
}
}
@@ -89,32 +89,96 @@ public protocol VideoPlayerEngine: AnyObject {
/// Was über die Leitung kommt, während gespielt wird.
public struct PlaybackStatistics: Sendable, Equatable {
/// Was tatsächlich ankommt, in Kilobit je Sekunde.
///
/// **Nachlaufend und leicht misszuverstehen.** Spielt der Player
/// zufrieden eine kleine Sprosse, holt er wenig — und dieser Wert sieht
/// schlecht aus, ohne dass die Leitung schlecht wäre. Als alleiniges
/// Kriterium für eine Regelung taugt er deshalb nicht.
public let observedBitrate: Int?
/// Was der Stream für sich angibt.
/// Was der Stream für sich angibt — also auf welcher Sprosse man steht.
public let indicatedBitrate: Int?
public let width: Int?
public let height: Int?
/// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung.
public let droppedFrames: Int?
/// Wie viele Stufen die Playlist überhaupt anbietet.
///
/// **Die wichtigste Zahl hier**, und die einzige, die die Frage
/// beantwortet, ob es etwas zum Umschalten gibt. Sie kommt vom Parser des
/// Players, nicht aus einer Vermutung über den Server: `1` heißt, dass
/// alles Nachregeln ins Leere greift, ganz gleich was bestellt wurde.
///
/// `nil` bei allem, was keine Playlist ist — eine Datei hat keine Leiter,
/// und das ist die richtige Aussage, nicht „null Stufen".
public let variantCount: Int?
/// Die Sprossen selbst, aufsteigend, in Kilobit je Sekunde.
///
/// Nötig, um eine Stufe tiefer zu **zielen**: ein Wert zwischen zwei
/// Sprossen wählt dieselbe wie die untere und macht jede Anzeige darüber
/// zur Lüge.
public let variantBitrates: [Int]
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// **Der einzige echte Frühwarner.** Schrumpft er schneller, als die Zeit
/// vergeht, kommt weniger herein als hinausgeht — und das ist zehn bis
/// zwanzig Sekunden vor dem Rückler zu sehen. Alles andere meldet sich
/// erst, wenn das Bild schon steht.
public let bufferedAhead: Duration?
/// Apples eigene Einschätzung. Früh, aber grob.
public let isLikelyToKeepUp: Bool?
/// Wie oft es seit dem Start gestockt hat.
public let stallCount: Int
/// Wie lange insgesamt gewartet wurde.
public let stalledTime: Duration
public init(
observedBitrate: Int? = nil,
indicatedBitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
droppedFrames: Int? = nil
droppedFrames: Int? = nil,
variantCount: Int? = nil,
variantBitrates: [Int] = [],
bufferedAhead: Duration? = nil,
isLikelyToKeepUp: Bool? = nil,
stallCount: Int = 0,
stalledTime: Duration = .zero
) {
self.observedBitrate = observedBitrate
self.indicatedBitrate = indicatedBitrate
self.width = width
self.height = height
self.droppedFrames = droppedFrames
self.variantCount = variantCount
self.variantBitrates = variantBitrates
self.bufferedAhead = bufferedAhead
self.isLikelyToKeepUp = isLikelyToKeepUp
self.stallCount = stallCount
self.stalledTime = stalledTime
}
public var resolution: String? {
guard let width, let height, width > 0, height > 0 else { return nil }
return "\(width)×\(height)"
}
/// Ob es überhaupt etwas zum Umschalten gibt.
///
/// Bei `false` ist `preferredPeakBitRate` wirkungslos — AVPlayer spielt
/// die eine Stufe auch dann, wenn sie über der Grenze liegt. Genau dafür
/// gibt es die Sicherheitsleine.
public var hasLadder: Bool { (variantCount ?? 0) > 1 }
/// Die nächste Sprosse unterhalb der angegebenen — oder die unterste.
///
/// Gibt `nil` zurück, wenn es nichts Tieferes gibt. Dann ist der billige
/// Weg erschöpft.
public func rung(below bitrate: Int) -> Int? {
variantBitrates.last { $0 < bitrate }
}
}
extension VideoPlayerEngine {