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
@@ -47,8 +47,10 @@ public struct PlaybackDecision: Sendable, Equatable {
|
||||
public var engine: PlaybackEngineKind? {
|
||||
switch method {
|
||||
case .directPlay(let engine): engine
|
||||
// Der Transcoder liefert HLS — das ist genau die Kost, für die
|
||||
// AVPlayer gebaut ist, inklusive automatischem Bitratenwechsel.
|
||||
// Der Transcoder liefert HLS, und damit ist AVPlayer der richtige
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
|
||||
@@ -6,10 +6,19 @@ extension PlexServer {
|
||||
|
||||
/// Die Adresse des HLS-Streams für diese Wiedergabe.
|
||||
///
|
||||
/// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die
|
||||
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
|
||||
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung —
|
||||
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format.
|
||||
/// HLS ist die Kost, für die AVPlayer gebaut ist — **aber nur, wenn die
|
||||
/// Playlist mehr als eine Stufe hat.**
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Hier steckt der Unterschied zwischen einer laufenden Sendung und
|
||||
|
||||
Reference in New Issue
Block a user