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:
co-authored by
Claude Opus 5
parent
9c66727aca
commit
903bfe5f63
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user