Player: die Qualitätsleiter des Servers, Bitrate im Blick, Doppeltipp

Die vier erfundenen Stufen weichen der Leiter, die der Server selbst
meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000,
12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine
eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt —
er rundet dann still auf die nächste, und die Anzeige log.

Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p
hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus.

Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene
Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten
Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere,
was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort.

„Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen
reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild
ausgeblendet.

Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die
Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt
sonst das doppelte.

Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder
rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im
Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören.
Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg,
er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben.

Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler.
Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 21:50:39 +02:00
co-authored by Claude Opus 5
parent c2ddc10871
commit 12bb49615b
28 changed files with 666 additions and 262 deletions
@@ -136,6 +136,30 @@ public final class AVPlayerEngine: VideoPlayerEngine {
select(track, in: .legible)
}
/// Was gerade über die Leitung kommt.
///
/// Aus dem Zugriffsprotokoll des Players: es führt Buch über jede
/// gezogene Stufe. Der **letzte** Eintrag ist der aktuelle; die früheren
/// beschreiben, was vorher lief.
public var statistics: PlaybackStatistics? {
guard let item = player.currentItem else { return nil }
let event = item.accessLog()?.events.last
func kilobits(_ value: Double?) -> Int? {
guard let value, value > 0 else { return nil }
return Int((value / 1_000).rounded())
}
let size = item.presentationSize
return PlaybackStatistics(
observedBitrate: kilobits(event?.observedBitrate),
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) }
)
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
@@ -192,7 +216,7 @@ public final class AVPlayerEngine: VideoPlayerEngine {
// Kein `assumeIsolated`: KVO meldet auf dem Thread, der die
// Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand
// auch aus internen Threads. Genau dieselbe Annahme hat die App
// im VLC-Motor zum Absturz gebracht.
// im VLC-Engine zum Absturz gebracht.
Task { @MainActor in
guard let self else { return }
switch player.timeControlStatus {
@@ -5,7 +5,7 @@ import PlexKit
///
/// Bewusst getrennt von `PlexStream`: VLCKit und AVPlayer nummerieren ihre
/// Spuren selbst, und die Nummern stimmen nicht mit denen von Plex überein.
/// `engineIdentifier` hält deshalb fest, wie der jeweilige Motor die Spur
/// `engineIdentifier` hält deshalb fest, wie der jeweilige Engine die Spur
/// nennt, während `plexStreamID` für den Server gedacht ist.
public struct MediaTrack: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable { case audio, subtitle }
@@ -15,7 +15,7 @@ public struct MediaTrack: Sendable, Equatable, Identifiable {
public let displayName: String
public let languageCode: String?
public let plexStreamID: Int?
/// Die Nummer, unter der der laufende Motor die Spur führt.
/// Die Nummer, unter der der laufende Engine die Spur führt.
public let engineIdentifier: Int?
public let isForced: Bool
@@ -1,6 +1,6 @@
import Foundation
/// Der Zustand der Wiedergabe, unabhängig vom Motor.
/// Der Zustand der Wiedergabe, unabhängig vom Engine.
public enum PlaybackState: Sendable, Equatable {
case idle
/// Quelle wird geöffnet, es ist noch nichts zu sehen.
@@ -21,12 +21,12 @@ public enum PlaybackState: Sendable, Equatable {
}
/// Ein Fehler bei der Wiedergabe, in der Sprache der App statt in der des
/// jeweiligen Motors.
/// jeweiligen Engine.
public struct PlaybackFailure: Sendable, Equatable, Error {
public enum Kind: Sendable, Equatable {
/// Die Quelle war nicht erreichbar.
case sourceUnavailable
/// Der Motor kann das Format nicht — sollte nach einer korrekten
/// Der Engine kann das Format nicht — sollte nach einer korrekten
/// Entscheidung nicht vorkommen und ist deshalb ein Hinweis darauf,
/// dass die Fähigkeitenliste nachgezogen werden muss.
case formatUnsupported
@@ -3,7 +3,7 @@ import Foundation
/// Woher das Bild kommt.
public struct PlaybackSource: Sendable, Equatable {
public enum Origin: Sendable, Equatable {
/// Vom Server — per DirectPlay oder als HLS-Strom des Transcoders.
/// Vom Server — per DirectPlay oder als HLS-Stream des Transcoders.
case remote
/// Aus dem Download-Ordner.
case local
@@ -22,11 +22,11 @@ public struct PlaybackSource: Sendable, Equatable {
}
}
/// Was ein Motor von sich aus meldet.
/// Was eine Engine von sich aus meldet.
///
/// Ein Strom statt beobachtbarer Eigenschaften: AVPlayer meldet über KVO,
/// Ein Stream statt beobachtbarer Eigenschaften: AVPlayer meldet über KVO,
/// VLCKit über Delegat-Rückrufe. Beide in dieselbe Ereignisfolge zu
/// übersetzen hält den Unterschied an genau einer Stelle je Motor — statt in
/// übersetzen hält den Unterschied an genau einer Stelle je Engine — statt in
/// jeder View, die den Zustand anzeigt.
public enum PlaybackEvent: Sendable, Equatable {
case stateChanged(PlaybackState)
@@ -40,7 +40,7 @@ public enum PlaybackEvent: Sendable, Equatable {
///
/// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
/// Was hier nicht steht, gibt es für die Views nicht; dadurch bleibt der
/// Austausch des Motors unsichtbar.
/// Austausch des Engine unsichtbar.
@MainActor
public protocol VideoPlayerEngine: AnyObject {
var events: AsyncStream<PlaybackEvent> { get }
@@ -49,7 +49,7 @@ public protocol VideoPlayerEngine: AnyObject {
var currentTime: Duration { get }
var duration: Duration? { get }
/// Ob dieser Motor Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
/// Ob dieser Engine Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
/// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist
/// schlimmer als keiner.
var supportsPictureInPicture: Bool { get }
@@ -66,7 +66,52 @@ public protocol VideoPlayerEngine: AnyObject {
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?)
/// Was gerade tatsächlich über die Leitung kommt.
///
/// Nicht, was angefordert wurde: bei einem HLS-Stream entscheidet der
/// Player selbst, welche Stufe er zieht, und die kann von der gewählten
/// Obergrenze abweichen. Ohne diese Angabe bleibt „ruckelt es, weil die
/// Leitung schlecht ist?" eine Vermutung.
var statistics: PlaybackStatistics? { get }
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen,
/// sonst behält besonders VLCKit den Speicher.
func teardown()
}
/// Was über die Leitung kommt, während gespielt wird.
public struct PlaybackStatistics: Sendable, Equatable {
/// Was tatsächlich ankommt, in Kilobit je Sekunde.
public let observedBitrate: Int?
/// Was der Stream für sich angibt.
public let indicatedBitrate: Int?
public let width: Int?
public let height: Int?
/// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung.
public let droppedFrames: Int?
public init(
observedBitrate: Int? = nil,
indicatedBitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
droppedFrames: Int? = nil
) {
self.observedBitrate = observedBitrate
self.indicatedBitrate = indicatedBitrate
self.width = width
self.height = height
self.droppedFrames = droppedFrames
}
public var resolution: String? {
guard let width, let height, width > 0, height > 0 else { return nil }
return "\(width)×\(height)"
}
}
extension VideoPlayerEngine {
/// Engines, die nichts melden, melden nichts — statt jeden zu zwingen.
public var statistics: PlaybackStatistics? { nil }
}