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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user