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.",
"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.",
"isCommentAutoGenerated" : true,
@@ -17,8 +21,8 @@
}
}
},
"%@ 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.",
"1080p" : {
"comment" : "A label for a 1080p video.",
"isCommentAutoGenerated" : true
},
"4K" : {
@@ -29,8 +33,8 @@
"comment" : "A label for a 720p video.",
"isCommentAutoGenerated" : true
},
"1080p" : {
"comment" : "A label for a 1080p video.",
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"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" : {
"extractionState" : "manual",
"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" : {
"extractionState" : "stale",
"localizations" : {
@@ -3522,6 +3526,23 @@
}
}
},
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet",
"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" : {
"extractionState" : "stale",
"localizations" : {
@@ -3641,23 +3645,6 @@
}
}
},
"quality.automatic" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatisch"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatic"
}
}
}
},
"quality.autoShort" : {
"extractionState" : "stale",
"localizations" : {
@@ -3675,6 +3662,23 @@
}
}
},
"quality.automatic" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatisch"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatic"
}
}
}
},
"quality.current" : {
"localizations" : {
"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" : {
"extractionState" : "stale",
"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" : {
"extractionState" : "stale",
"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" : {
"localizations" : {
"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" : {
"comment" : "Laufzeit mit Stunden",
"extractionState" : "stale",
@@ -3999,10 +4105,6 @@
}
}
},
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint" : {
"localizations" : {
"de" : {
+79
View File
@@ -176,6 +176,27 @@ final class PlayerModel {
private var programmeEndsAt: Date?
/// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird.
@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
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
/// lassen — und man käme nie zurück.
@@ -483,6 +504,57 @@ final class PlayerModel {
// 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.
///
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
@@ -596,6 +668,11 @@ final class PlayerModel {
isPlaying = false
isBuffering = false
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)
}
@@ -778,8 +855,10 @@ final class PlayerModel {
guard let self else { return }
switch event {
case .stateChanged(let state):
let wasPlaying = isPlaying
isPlaying = state == .playing
isBuffering = state == .buffering || state == .loading
noteStalling(state, wasPlaying: wasPlaying)
watchBuffering(isBuffering)
NowPlaying.shared.update(from: self)
// 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 {
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: {
Text("quality.current")
} footer: {
// Der Unterschied ist keine Haarspalterei: die Obergrenze sagt,
// was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es
// 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.
///
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"