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
+161 -59
View File
@@ -5,6 +5,10 @@
"comment" : "A separator between different parts of the episode metadata.", "comment" : "A separator between different parts of the episode metadata.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"%@ GB" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true
},
"%@ → %@" : { "%@ → %@" : {
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", "comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -17,8 +21,8 @@
} }
} }
}, },
"%@ GB" : { "1080p" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", "comment" : "A label for a 1080p video.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"4K" : { "4K" : {
@@ -29,8 +33,8 @@
"comment" : "A label for a 720p video.", "comment" : "A label for a 720p video.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"1080p" : { "SD" : {
"comment" : "A label for a 1080p video.", "comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"account.hint" : { "account.hint" : {
@@ -2850,6 +2854,23 @@
} }
} }
}, },
"liveTV.recordSeries" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serie aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record series"
}
}
}
},
"liveTV.recording.inProgress" : { "liveTV.recording.inProgress" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -2918,23 +2939,6 @@
} }
} }
}, },
"liveTV.recordSeries" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serie aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record series"
}
}
}
},
"liveTV.runningNow" : { "liveTV.runningNow" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3522,6 +3526,23 @@
} }
} }
}, },
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.subtitlesOff" : { "player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet", "comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale", "extractionState" : "stale",
@@ -3540,23 +3561,6 @@
} }
} }
}, },
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.tracks" : { "player.tracks" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3641,23 +3645,6 @@
} }
} }
}, },
"quality.automatic" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatisch"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatic"
}
}
}
},
"quality.autoShort" : { "quality.autoShort" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3675,6 +3662,23 @@
} }
} }
}, },
"quality.automatic" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatisch"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatic"
}
}
}
},
"quality.current" : { "quality.current" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3708,6 +3712,23 @@
} }
} }
}, },
"quality.current.buffer" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Puffer"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Buffer"
}
}
}
},
"quality.current.dropped" : { "quality.current.dropped" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3741,6 +3762,23 @@
} }
} }
}, },
"quality.current.noLadder" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dieser Strom hat nur eine Stufe — es gibt nichts, worauf der Player ausweichen könnte. Bei Rücklern hilft hier nur eine kleinere Qualität von Hand."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This stream has a single variant — there is nothing for the player to fall back to. If it stutters, only a manually chosen lower quality helps."
}
}
}
},
"quality.current.resolution" : { "quality.current.resolution" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3758,6 +3796,40 @@
} }
} }
}, },
"quality.current.stalls" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Rückler"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stalls"
}
}
}
},
"quality.current.variants" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Stufen in der Playlist"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Variants in playlist"
}
}
}
},
"quality.everyRung" : { "quality.everyRung" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3963,6 +4035,40 @@
} }
} }
}, },
"quality.seconds %@" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ s"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ s"
}
}
}
},
"quality.stalls %lld %@" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld · %@ gewartet"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld · %@ waiting"
}
}
}
},
"runtime.hoursMinutes %lld %lld" : { "runtime.hoursMinutes %lld %lld" : {
"comment" : "Laufzeit mit Stunden", "comment" : "Laufzeit mit Stunden",
"extractionState" : "stale", "extractionState" : "stale",
@@ -3999,10 +4105,6 @@
} }
} }
}, },
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint" : { "search.hint" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
+79
View File
@@ -176,6 +176,27 @@ final class PlayerModel {
private var programmeEndsAt: Date? private var programmeEndsAt: Date?
/// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird. /// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird.
@ObservationIgnored private var bufferWatchdog: Task<Void, Never>? @ObservationIgnored private var bufferWatchdog: Task<Void, Never>?
/// 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.
///
/// Erst ab dem ersten Bild, nicht ab dem Antippen: das anfängliche
/// Puffern ist der Start und kein Rückler.
@ObservationIgnored private var playbackStartedAt: ContinuousClock.Instant?
/// Wann das laufende Stocken begann, gemessen ab `playbackStartedAt`.
@ObservationIgnored private var stallBegan: Duration?
/// Ob die Leitung diese Wiedergabe gerade nicht trägt.
///
/// Nicht „es hat einmal gehakt" — das tut jede Verbindung. Sondern: in
/// den letzten drei Minuten ging ein Viertel der Zeit fürs Warten drauf,
/// oder ein einzelner Rückler dauerte länger als fünfzehn Sekunden.
///
/// Das ist der Befund, aus dem später das Herunterschalten folgt — und
/// schon jetzt der Beleg, auf den sich ein Hinweis in den Einstellungen
/// stützen darf, statt auf eine Vermutung.
private(set) var isConnectionStruggling = false
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses /// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen /// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
/// lassen — und man käme nie zurück. /// lassen — und man käme nie zurück.
@@ -483,6 +504,57 @@ final class PlayerModel {
// MARK: - Steuerung // MARK: - Steuerung
/// Führt Buch über das Stocken — die **zweite** Uhr.
///
/// Der Wachhund darunter ist ein Totmann: vierzig Sekunden am Stück ohne
/// Daten, dann Schluss. Er setzt sich bei jedem kurzen Erholen zurück,
/// und genau das ist sein blinder Fleck: zehn Rückler à dreißig Sekunden
/// lösen ihn nie aus, obwohl die halbe Stunde unbrauchbar war.
///
/// Dieses Buch zählt stattdessen mit, wie viel Zeit im gleitenden Fenster
/// fürs Warten draufging. Es **bricht nichts ab** — es stellt nur fest.
private func noteStalling(_ state: PlaybackState, wasPlaying: Bool) {
guard let startedAt = playbackStartedAt else {
// Der Nullpunkt ist das erste Bild, nicht das Antippen.
if state == .playing { playbackStartedAt = .now }
return
}
let now = ContinuousClock.now - startedAt
if state == .buffering {
// Nur aus dem Spielen heraus. Ein Stocken direkt nach einem
// Sprung oder beim Umschalten der Stufe ist keine Auskunft über
// die Leitung, sondern über das, was wir gerade selbst getan
// haben.
if wasPlaying, stallBegan == nil { stallBegan = now }
} else if let began = stallBegan {
let episode = StallLedger.Episode(endedAt: now, length: now - began)
stallLedger.record(episode)
stallBegan = nil
judgeConnection(at: now, episode: episode)
}
}
/// Fällt das Urteil über die Leitung — und schreibt es auf.
private func judgeConnection(at now: Duration, episode: StallLedger.Episode) {
let share = stallLedger.share(at: now)
let struggling = share >= 0.25 || episode.length >= .seconds(15)
guard struggling != isConnectionStruggling else { return }
isConnectionStruggling = struggling
if struggling {
diagnostics.note(String(
format: "Leitung trägt nicht: %d Rückler, %.0f %% Wartezeit",
stallLedger.count(at: now),
share * 100
))
} else {
diagnostics.note("Leitung hat sich erholt")
}
}
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel. /// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
/// ///
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner /// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
@@ -596,6 +668,11 @@ final class PlayerModel {
isPlaying = false isPlaying = false
isBuffering = false isBuffering = false
bufferWatchdog?.cancel() bufferWatchdog?.cancel()
// Das Buch gehört zur Quelle. Ein neuer Titel fängt bei null an.
stallLedger.removeAll()
playbackStartedAt = nil
stallBegan = nil
isConnectionStruggling = false
NowPlaying.shared.release(for: self) NowPlaying.shared.release(for: self)
} }
@@ -778,8 +855,10 @@ final class PlayerModel {
guard let self else { return } guard let self else { return }
switch event { switch event {
case .stateChanged(let state): case .stateChanged(let state):
let wasPlaying = isPlaying
isPlaying = state == .playing isPlaying = state == .playing
isBuffering = state == .buffering || state == .loading isBuffering = state == .buffering || state == .loading
noteStalling(state, wasPlaying: wasPlaying)
watchBuffering(isBuffering) watchBuffering(isBuffering)
NowPlaying.shared.update(from: self) NowPlaying.shared.update(from: self)
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
+39 -1
View File
@@ -86,13 +86,44 @@ struct QualitySheet: View {
if let dropped = statistics.droppedFrames, dropped > 0 { if let dropped = statistics.droppedFrames, dropped > 0 {
LabeledContent("quality.current.dropped", value: "\(dropped)") LabeledContent("quality.current.dropped", value: "\(dropped)")
} }
// **Die Zahl, die alles entscheidet.** Eine Playlist mit
// einer Stufe hat nichts zum Umschalten — dann greift jedes
// Nachregeln ins Leere, ganz gleich was bestellt wurde.
if let variants = statistics.variantCount {
LabeledContent("quality.current.variants", value: "\(variants)")
}
// Der Puffer ist der einzige echte Frühwarner: schrumpft er
// schneller, als die Zeit vergeht, kommt weniger herein als
// hinausgeht.
if let buffer = statistics.bufferedAhead {
LabeledContent("quality.current.buffer", value: secondsText(buffer))
}
if statistics.stallCount > 0 {
LabeledContent(
"quality.current.stalls",
value: String(
localized: "quality.stalls \(statistics.stallCount) \(secondsText(statistics.stalledTime))"
)
)
}
} header: { } header: {
Text("quality.current") Text("quality.current")
} footer: { } footer: {
// Der Unterschied ist keine Haarspalterei: die Obergrenze sagt, // Der Unterschied ist keine Haarspalterei: die Obergrenze sagt,
// was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es // was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es
// trotz hoher Stufe, steht die Antwort hier. // trotz hoher Stufe, steht die Antwort hier.
Text("quality.current.hint") //
// Und wenn es nur eine Stufe gibt, gehört auch das gesagt:
// sonst sucht man den Fehler beim Player, während in
// Wahrheit nie eine Leiter bestellt wurde.
if statistics.variantCount == 1 {
Text("quality.current.noLadder")
} else {
Text("quality.current.hint")
}
} }
} }
} }
@@ -146,6 +177,13 @@ struct QualitySheet: View {
} }
} }
/// Sekunden mit einer Nachkommastelle — für Puffer und Wartezeit.
private func secondsText(_ duration: Duration) -> String {
let seconds = Double(duration.components.seconds)
+ Double(duration.components.attoseconds) / 1e18
return String(localized: "quality.seconds \(String(format: "%.1f", seconds))")
}
/// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle. /// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle.
/// ///
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps" /// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"
@@ -47,8 +47,10 @@ public struct PlaybackDecision: Sendable, Equatable {
public var engine: PlaybackEngineKind? { public var engine: PlaybackEngineKind? {
switch method { switch method {
case .directPlay(let engine): engine case .directPlay(let engine): engine
// Der Transcoder liefert HLS — das ist genau die Kost, für die // Der Transcoder liefert HLS, und damit ist AVPlayer der richtige
// AVPlayer gebaut ist, inklusive automatischem Bitratenwechsel. // Motor. **Nicht** aber, wie hier früher stand, „inklusive
// automatischem Bitratenwechsel": den gibt es nur, wenn die Playlist
// mehrere Stufen führt, und das muss ausdrücklich bestellt werden.
case .transcode: .avPlayer case .transcode: .avPlayer
} }
} }
@@ -0,0 +1,107 @@
import Foundation
/// Buch über das Stocken, mit gleitendem Fenster.
///
/// **Warum es das gibt.** Der Wachhund im Player kannte nur eine Frage: steht
/// es seit vierzig Sekunden am Stück? Bei jedem kurzen Erholen setzte er sich
/// zurück — zehn Rückler à dreißig Sekunden lösten nichts aus, obwohl die
/// halbe Stunde unbrauchbar war. Genau dieser Fall ist der, über den geklagt
/// wird: nicht der tote Strom, sondern der, der immer wieder hakt.
///
/// **Warum als eigener Typ.** Keine Uhr, kein AVFoundation, kein Netz — die
/// Zeiten kommen von außen herein. Damit lässt sich eine Folge von Rücklern
/// als Zahlenreihe hinschreiben und das Urteil prüfen. Eine Regel, die man
/// nur im laufenden Film beobachten kann, ist keine Regel, sondern ein
/// Eindruck.
///
/// Alle Zeitpunkte sind **seit Beginn dieser Wiedergabe** gemessen, nicht als
/// Datum. Das macht die Sache gegen Zeitzonen, Sommerzeit und eine Systemuhr,
/// die sich mitten im Film stellt, gleichgültig.
public struct StallLedger: Sendable, Equatable {
public struct Episode: Sendable, Equatable {
/// Wann dieser Rückler vorbei war.
public let endedAt: Duration
public let length: Duration
public init(endedAt: Duration, length: Duration) {
self.endedAt = endedAt
self.length = length
}
}
/// Wie weit zurück gezählt wird.
///
/// Drei Minuten: lang genug, dass ein Muster erkennbar wird, kurz genug,
/// dass eine überstandene Durststrecke nicht noch eine Viertelstunde
/// später nachwirkt.
public let window: Duration
private(set) var episodes: [Episode] = []
public init(window: Duration = .seconds(180)) {
self.window = window
}
/// Trägt einen überstandenen Rückler ein.
public mutating func record(_ episode: Episode) {
episodes.append(episode)
prune(at: episode.endedAt)
}
/// Wie viele Rückler im Fenster liegen.
public func count(at now: Duration) -> Int {
episodes.count { $0.endedAt >= now - window }
}
/// Welcher Anteil des Fensters mit Warten verbracht wurde — 0 bis 1.
///
/// Der laufende Rückler zählt mit, sofern er übergeben wird: wer mitten
/// im dritten Stocken steht, soll nicht erst nach dessen Ende erfahren,
/// dass es zu viel war.
public func share(at now: Duration, ongoing: Duration = .zero) -> Double {
let horizon = now - window
let waited = episodes
.filter { $0.endedAt >= horizon }
.reduce(Duration.zero) { $0 + $1.length } + ongoing
// Am Anfang ist das Fenster noch nicht voll. Dann gegen die
// tatsächlich vergangene Zeit rechnen, sonst sähe eine Wiedergabe,
// die nach zwanzig Sekunden fünf Sekunden hakte, mit 3 % harmlos aus
// statt mit 25 %.
let elapsed = min(now, window)
guard elapsed > .zero else { return 0 }
return min(1, waited / elapsed)
}
/// Der längste einzelne Rückler im Fenster.
///
/// Ein einzelner langer ist etwas anderes als viele kurze: der eine sagt
/// „die Leitung war weg", die vielen sagen „sie trägt es dauerhaft nicht".
/// Beide sollen auslösen dürfen, aber nicht über dieselbe Schwelle.
public func longest(at now: Duration) -> Duration {
episodes
.filter { $0.endedAt >= now - window }
.map(\.length)
.max() ?? .zero
}
/// Wirft heraus, was aus dem Fenster gefallen ist.
public mutating func prune(at now: Duration) {
let horizon = now - window
episodes.removeAll { $0.endedAt < horizon }
}
public mutating func removeAll() {
episodes.removeAll()
}
}
private extension Duration {
/// Anteil zweier Dauern. `Duration` kann sich nicht selbst teilen.
static func / (lhs: Duration, rhs: Duration) -> Double {
let left = Double(lhs.components.seconds) + Double(lhs.components.attoseconds) / 1e18
let right = Double(rhs.components.seconds) + Double(rhs.components.attoseconds) / 1e18
guard right != 0 else { return 0 }
return left / right
}
}
@@ -17,6 +17,7 @@ public final class AVPlayerEngine: VideoPlayerEngine {
public private(set) var state: PlaybackState = .idle { public private(set) var state: PlaybackState = .idle {
didSet { didSet {
guard state != oldValue else { return } guard state != oldValue else { return }
noteStall(from: oldValue, to: state)
continuation?.yield(.stateChanged(state)) continuation?.yield(.stateChanged(state))
} }
} }
@@ -50,6 +51,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
private var pendingStartTime: Duration = .zero private var pendingStartTime: Duration = .zero
private var observations: [NSKeyValueObservation] = [] private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)? 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() { public init() {
AudioSession.activate() AudioSession.activate()
@@ -59,6 +78,13 @@ public final class AVPlayerEngine: VideoPlayerEngine {
teardownObservers() teardownObservers()
state = .loading 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 pendingStartTime = source.startTime
let asset = AVURLAsset(url: source.url, options: [ let asset = AVURLAsset(url: source.url, options: [
@@ -100,6 +126,23 @@ public final class AVPlayerEngine: VideoPlayerEngine {
continuation?.yield(.durationResolved(value)) 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 } guard !Task.isCancelled else { return }
// Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem // Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem
// laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein // 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. /// beschreiben, was vorher lief.
public var statistics: PlaybackStatistics? { public var statistics: PlaybackStatistics? {
guard let item = player.currentItem else { return nil } 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? { func kilobits(_ value: Double?) -> Int? {
guard let value, value > 0 else { return nil } guard let value, value > 0 else { return nil }
@@ -157,10 +201,68 @@ public final class AVPlayerEngine: VideoPlayerEngine {
indicatedBitrate: kilobits(event?.indicatedBitrate), indicatedBitrate: kilobits(event?.indicatedBitrate),
width: size.width > 0 ? Int(size.width) : nil, width: size.width > 0 ? Int(size.width) : nil,
height: size.height > 0 ? Int(size.height) : 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() { public func teardown() {
teardownObservers() teardownObservers()
player.replaceCurrentItem(with: nil) 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( endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification, forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item, object: item,
@@ -307,6 +423,10 @@ public final class AVPlayerEngine: VideoPlayerEngine {
NotificationCenter.default.removeObserver(endObserver) NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil 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. /// Was über die Leitung kommt, während gespielt wird.
public struct PlaybackStatistics: Sendable, Equatable { public struct PlaybackStatistics: Sendable, Equatable {
/// Was tatsächlich ankommt, in Kilobit je Sekunde. /// 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? 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 indicatedBitrate: Int?
public let width: Int? public let width: Int?
public let height: Int? public let height: Int?
/// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung. /// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung.
public let droppedFrames: Int? 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( public init(
observedBitrate: Int? = nil, observedBitrate: Int? = nil,
indicatedBitrate: Int? = nil, indicatedBitrate: Int? = nil,
width: Int? = nil, width: Int? = nil,
height: 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.observedBitrate = observedBitrate
self.indicatedBitrate = indicatedBitrate self.indicatedBitrate = indicatedBitrate
self.width = width self.width = width
self.height = height self.height = height
self.droppedFrames = droppedFrames 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? { public var resolution: String? {
guard let width, let height, width > 0, height > 0 else { return nil } guard let width, let height, width > 0, height > 0 else { return nil }
return "\(width)×\(height)" 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 { extension VideoPlayerEngine {
@@ -6,10 +6,19 @@ extension PlexServer {
/// Die Adresse des HLS-Streams für diese Wiedergabe. /// Die Adresse des HLS-Streams für diese Wiedergabe.
/// ///
/// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die /// HLS ist die Kost, für die AVPlayer gebaut ist — **aber nur, wenn die
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung /// Playlist mehr als eine Stufe hat.**
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung — ///
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format. /// Hier stand, der Player wechsle die Bitrate von selbst, und das sei die
/// zugesagte automatische Anpassung. Das war falsch: umschalten kann er
/// nur zwischen den Varianten, die in der Liste stehen, und diese
/// Anfrage bestellt über `maxVideoBitrate` und `videoQuality` genau eine.
/// Bei einer Variante gibt es nichts zu wechseln — und genau deshalb
/// ruckelt es auf einer Leitung, die einbricht.
///
/// Wie viele Stufen tatsächlich ankommen, steht in
/// `PlaybackStatistics.variantCount`. Das ist der Parser des Players und
/// damit die einzige belastbare Auskunft darüber.
/// - Parameter keepPathEncoding: Ob `path` unverändert eingesetzt wird. /// - Parameter keepPathEncoding: Ob `path` unverändert eingesetzt wird.
/// ///
/// Hier steckt der Unterschied zwischen einer laufenden Sendung und /// Hier steckt der Unterschied zwischen einer laufenden Sendung und
@@ -0,0 +1,89 @@
import Testing
import Foundation
@testable import FlixyPlayback
@Suite("Rückler-Buchführung")
struct StallLedgerTests {
/// Der Fall, wegen dem es den Typ gibt.
///
/// Zehn Rückler à dreißig Sekunden lösten beim alten Wachhund nichts aus,
/// weil er sich bei jedem Erholen zurücksetzte. Hier muss sichtbar
/// werden, dass die Wiedergabe unbrauchbar ist.
@Test("Viele kurze Rückler bleiben sichtbar")
func manyShortStallsAreVisible() {
var ledger = StallLedger(window: .seconds(180))
// Alle 20 s ein Rückler von 5 s, drei Minuten lang.
for step in 1...9 {
ledger.record(.init(endedAt: .seconds(step * 20), length: .seconds(5)))
}
let now = Duration.seconds(180)
#expect(ledger.count(at: now) == 9)
// 45 s Warten in 180 s Fenster.
#expect(ledger.share(at: now) > 0.2)
#expect(ledger.share(at: now) < 0.3)
}
@Test("Was aus dem Fenster fällt, zählt nicht mehr")
func oldEpisodesFallOut() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(10), length: .seconds(30)))
#expect(ledger.count(at: .seconds(60)) == 1)
// Weit hinter dem Fenster: vergessen.
#expect(ledger.count(at: .seconds(300)) == 0)
#expect(ledger.share(at: .seconds(300)) == 0)
}
/// Am Anfang ist das Fenster noch nicht voll — dann gegen die tatsächlich
/// vergangene Zeit rechnen, sonst sieht ein früher Rückler harmlos aus.
@Test("Früh im Film zählt die vergangene Zeit, nicht das ganze Fenster")
func earlyStallsAreNotDiluted() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(20), length: .seconds(5)))
let share = ledger.share(at: .seconds(20))
// 5 s von 20 s, nicht 5 s von 180 s.
#expect(share > 0.2)
#expect(share < 0.3)
}
@Test("Der laufende Rückler zählt mit")
func ongoingStallCounts() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(10)))
let without = ledger.share(at: .seconds(180))
let with = ledger.share(at: .seconds(180), ongoing: .seconds(20))
#expect(with > without)
}
/// Ein einzelner langer Rückler ist eine andere Aussage als viele kurze.
@Test("Der längste Einzelfall lässt sich getrennt ablesen")
func longestIsSeparate() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(30), length: .seconds(2)))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(18)))
ledger.record(.init(endedAt: .seconds(90), length: .seconds(3)))
#expect(ledger.longest(at: .seconds(120)) == .seconds(18))
#expect(ledger.count(at: .seconds(120)) == 3)
}
@Test("Eine ruhige Wiedergabe meldet nichts")
func quietPlaybackIsQuiet() {
let ledger = StallLedger()
#expect(ledger.count(at: .seconds(600)) == 0)
#expect(ledger.share(at: .seconds(600)) == 0)
#expect(ledger.longest(at: .seconds(600)) == .zero)
}
@Test("Der Anteil übersteigt nie eins")
func shareIsBounded() {
var ledger = StallLedger(window: .seconds(60))
// Absurd: mehr Wartezeit als Fenster.
ledger.record(.init(endedAt: .seconds(30), length: .seconds(200)))
#expect(ledger.share(at: .seconds(60)) == 1)
}
}