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:
co-authored by
Claude Opus 5
parent
c2ddc10871
commit
12bb49615b
@@ -3,17 +3,18 @@ import CoreGraphics
|
||||
|
||||
/// Wo die schwebende Videokachel sitzt.
|
||||
///
|
||||
/// Sie klebt an der linken oder rechten Seite und ist dort **frei in der
|
||||
/// Höhe** — nicht auf vier Ecken festgelegt. Vier Ecken sind schnell gebaut,
|
||||
/// aber im Weg: steht die Kachel unten rechts über einem Knopf, bleibt nur
|
||||
/// die Wahl zwischen vier Orten, von denen drei ebenso stören.
|
||||
///
|
||||
/// Eigener Typ statt Rechnungen in der Ansicht, aus demselben Grund wie bei
|
||||
/// `GuideLayout`: hier geht lautlos schief, was man nicht sieht. Eine Kachel,
|
||||
/// die halb aus dem Bild ragt, oder eine, die beim Loslassen in die falsche
|
||||
/// Ecke springt, fällt erst auf dem Gerät auf — und dann als Gefühl, nicht
|
||||
/// als Fehler.
|
||||
/// die halb aus dem Bild ragt, fällt erst auf dem Gerät auf — und dann als
|
||||
/// Gefühl, nicht als Fehler.
|
||||
public struct MiniPlayerPlacement: Sendable, Equatable {
|
||||
public enum Corner: String, Sendable, CaseIterable, Codable {
|
||||
case topLeading, topTrailing, bottomLeading, bottomTrailing
|
||||
|
||||
public var isTop: Bool { self == .topLeading || self == .topTrailing }
|
||||
public var isLeading: Bool { self == .topLeading || self == .bottomLeading }
|
||||
public enum Side: String, Sendable, CaseIterable, Codable {
|
||||
case leading, trailing
|
||||
}
|
||||
|
||||
/// Der sichtbare Bereich, in dem die Kachel sich bewegen darf.
|
||||
@@ -29,56 +30,52 @@ public struct MiniPlayerPlacement: Sendable, Equatable {
|
||||
self.inset = inset
|
||||
}
|
||||
|
||||
/// Die Ecke, in die eine losgelassene Kachel gehört.
|
||||
private var halfWidth: CGFloat { tile.width / 2 }
|
||||
private var halfHeight: CGFloat { tile.height / 2 }
|
||||
|
||||
private var minimumX: CGFloat { inset + halfWidth }
|
||||
private var maximumX: CGFloat { max(minimumX, bounds.width - inset - halfWidth) }
|
||||
private var minimumY: CGFloat { inset + halfHeight }
|
||||
private var maximumY: CGFloat { max(minimumY, bounds.height - inset - halfHeight) }
|
||||
|
||||
/// An welche Seite eine losgelassene Kachel gehört.
|
||||
///
|
||||
/// Entschieden wird nach dem **Mittelpunkt** der Kachel, nicht nach dem
|
||||
/// Finger: sonst springt eine Kachel, die man an der oberen Kante
|
||||
/// angefasst hat, in die falsche Hälfte.
|
||||
public func nearestCorner(toCenter center: CGPoint) -> Corner {
|
||||
let isLeading = center.x < bounds.width / 2
|
||||
let isTop = center.y < bounds.height / 2
|
||||
|
||||
switch (isTop, isLeading) {
|
||||
case (true, true): return .topLeading
|
||||
case (true, false): return .topTrailing
|
||||
case (false, true): return .bottomLeading
|
||||
case (false, false): return .bottomTrailing
|
||||
}
|
||||
/// Finger: sonst springt eine Kachel, die man am Rand angefasst hat, auf
|
||||
/// die falsche Seite.
|
||||
public func nearestSide(toCenter center: CGPoint) -> Side {
|
||||
center.x < bounds.width / 2 ? .leading : .trailing
|
||||
}
|
||||
|
||||
/// Die linke obere Ecke der Kachel für eine Ecke des Bildschirms.
|
||||
public func origin(for corner: Corner) -> CGPoint {
|
||||
CGPoint(
|
||||
x: corner.isLeading ? inset : max(inset, bounds.width - tile.width - inset),
|
||||
y: corner.isTop ? inset : max(inset, bounds.height - tile.height - inset)
|
||||
/// Wie weit unten die Kachel sitzt: 0 ganz oben, 1 ganz unten.
|
||||
///
|
||||
/// Als Anteil statt als Punktwert, damit die Kachel beim Drehen des Geräts
|
||||
/// an derselben *relativen* Stelle bleibt, statt unten herauszufallen.
|
||||
public func verticalFraction(forCenter center: CGPoint) -> Double {
|
||||
let travel = maximumY - minimumY
|
||||
guard travel > 0 else { return 0 }
|
||||
return min(max(Double((center.y - minimumY) / travel), 0), 1)
|
||||
}
|
||||
|
||||
public func center(side: Side, verticalFraction: Double) -> CGPoint {
|
||||
let fraction = min(max(verticalFraction, 0), 1)
|
||||
return CGPoint(
|
||||
x: side == .leading ? minimumX : maximumX,
|
||||
y: minimumY + (maximumY - minimumY) * CGFloat(fraction)
|
||||
)
|
||||
}
|
||||
|
||||
/// Der Mittelpunkt der Kachel in einer Ecke — damit rechnet SwiftUI.
|
||||
public func center(for corner: Corner) -> CGPoint {
|
||||
let start = origin(for: corner)
|
||||
return CGPoint(x: start.x + tile.width / 2, y: start.y + tile.height / 2)
|
||||
}
|
||||
|
||||
/// Hält einen Mittelpunkt im sichtbaren Bereich.
|
||||
///
|
||||
/// Während des Ziehens, damit die Kachel nicht hinter den Rand rutscht und
|
||||
/// dort unerreichbar wird — der Finger wäre dann schneller als das Ziel.
|
||||
public func clamped(center: CGPoint) -> CGPoint {
|
||||
let halfWidth = tile.width / 2
|
||||
let halfHeight = tile.height / 2
|
||||
|
||||
let minX = inset + halfWidth
|
||||
let maxX = bounds.width - inset - halfWidth
|
||||
let minY = inset + halfHeight
|
||||
let maxY = bounds.height - inset - halfHeight
|
||||
|
||||
return CGPoint(
|
||||
CGPoint(
|
||||
// `max(min…)` in dieser Reihenfolge, weil bei einem Bereich, der
|
||||
// schmaler ist als die Kachel, sonst der untere Anschlag verliert
|
||||
// und die Kachel rechts hinausragt.
|
||||
x: max(minX, min(maxX, center.x)),
|
||||
y: max(minY, min(maxY, center.y))
|
||||
x: max(minimumX, min(maximumX, center.x)),
|
||||
y: max(minimumY, min(maximumY, center.y))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public final class DownloadItem {
|
||||
///
|
||||
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
|
||||
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
|
||||
/// um zwischen den Motoren zu wählen.
|
||||
/// um zwischen die Engines zu wählen.
|
||||
public struct OfflineMedia: Sendable, Codable, Equatable {
|
||||
public var container: String?
|
||||
public var videoCodec: String?
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// Was AVPlayer auf diesem Gerät nativ abspielen kann.
|
||||
///
|
||||
/// Diese Liste ist der Grund, warum Flixy überhaupt zwei Motoren hat. iOS
|
||||
/// Diese Liste ist der Grund, warum Flixy überhaupt zwei Engines hat. iOS
|
||||
/// spielt weder Matroska-Dateien noch DTS- oder TrueHD-Ton — und genau das
|
||||
/// liegt in typischen Plex-Beständen. Alles, was hier nicht steht, geht an
|
||||
/// VLCKit (oder ab Phase 2 an den Transcoder).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Welcher Motor die Wiedergabe übernimmt.
|
||||
/// Welcher Engine die Wiedergabe übernimmt.
|
||||
public enum PlaybackEngineKind: String, Sendable, Equatable, Codable {
|
||||
/// Der Systemplayer. Bringt Bild-in-Bild, AirPlay und die Steuerung im
|
||||
/// Sperrbildschirm mit — deshalb hat er immer Vorrang, wenn er kann.
|
||||
|
||||
@@ -5,36 +5,106 @@ import Foundation
|
||||
/// Getrennt von den Einstellungen unter „Qualität": dort steht, was
|
||||
/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil
|
||||
/// es gerade ruckelt oder weil man ausnahmsweise das Original will.
|
||||
public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable {
|
||||
public enum PlayerQuality: Hashable, Sendable, Identifiable {
|
||||
/// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist.
|
||||
case automatic
|
||||
/// Die Datei unverändert, ohne Umrechnung.
|
||||
case original
|
||||
case fullHD
|
||||
case hd
|
||||
case low
|
||||
/// Eine Stufe der Leiter, die der Server anbietet.
|
||||
case limited(Rung)
|
||||
/// So wenig Bild wie möglich.
|
||||
case audioOnly
|
||||
|
||||
public var id: String { rawValue }
|
||||
/// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht.
|
||||
///
|
||||
/// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server
|
||||
/// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter
|
||||
/// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht
|
||||
/// kennt — er rundet dann still auf die nächste, und die Anzeige log.
|
||||
public struct Rung: Hashable, Sendable {
|
||||
public let bitrate: Int
|
||||
public let height: Int
|
||||
|
||||
public init(bitrate: Int, height: Int) {
|
||||
self.bitrate = bitrate
|
||||
self.height = height
|
||||
}
|
||||
|
||||
public var width: Int {
|
||||
// 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag
|
||||
// kein Kodierer.
|
||||
let raw = Int((Double(height) * 16.0 / 9.0).rounded())
|
||||
return raw % 2 == 0 ? raw : raw + 1
|
||||
}
|
||||
|
||||
public var resolution: String { "\(width)x\(height)" }
|
||||
}
|
||||
|
||||
/// Die Leiter von oben nach unten.
|
||||
public static let ladder: [Rung] = [
|
||||
Rung(bitrate: 20_000, height: 1080),
|
||||
Rung(bitrate: 12_000, height: 1080),
|
||||
Rung(bitrate: 10_000, height: 1080),
|
||||
Rung(bitrate: 8_000, height: 1080),
|
||||
Rung(bitrate: 4_000, height: 1080),
|
||||
Rung(bitrate: 3_000, height: 720),
|
||||
Rung(bitrate: 2_000, height: 720),
|
||||
Rung(bitrate: 1_500, height: 720),
|
||||
Rung(bitrate: 720, height: 480),
|
||||
Rung(bitrate: 320, height: 360),
|
||||
Rung(bitrate: 208, height: 360),
|
||||
Rung(bitrate: 96, height: 240),
|
||||
Rung(bitrate: 64, height: 160),
|
||||
]
|
||||
|
||||
public static var allCases: [PlayerQuality] {
|
||||
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
|
||||
}
|
||||
|
||||
public var id: String {
|
||||
switch self {
|
||||
case .automatic: "automatic"
|
||||
case .original: "original"
|
||||
case .audioOnly: "audioOnly"
|
||||
case .limited(let rung): "limited-\(rung.bitrate)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Obergrenze in Kilobit. `nil` bei `automatic` und `original`.
|
||||
public var maximumBitrate: Int? {
|
||||
switch self {
|
||||
case .automatic, .original: nil
|
||||
case .fullHD: 8_000
|
||||
case .hd: 4_000
|
||||
case .low: 2_000
|
||||
case .limited(let rung): rung.bitrate
|
||||
// Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse
|
||||
// ist das, was dem am nächsten kommt; das Bild wird dazu im Player
|
||||
// ausgeblendet, damit der Schirm dunkel bleiben kann.
|
||||
case .audioOnly: Self.ladder.last?.bitrate
|
||||
}
|
||||
}
|
||||
|
||||
public var resolution: String? {
|
||||
switch self {
|
||||
case .automatic, .original: nil
|
||||
case .fullHD: "1920x1080"
|
||||
case .hd: "1280x720"
|
||||
case .low: "854x480"
|
||||
case .limited(let rung): rung.resolution
|
||||
case .audioOnly: Self.ladder.last?.resolution
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt.
|
||||
public var height: Int? {
|
||||
switch self {
|
||||
case .automatic, .original: nil
|
||||
case .limited(let rung): rung.height
|
||||
case .audioOnly: Self.ladder.last?.height
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob das Bild gezeigt werden soll.
|
||||
public var showsVideo: Bool {
|
||||
if case .audioOnly = self { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Ob diese Stufe eine Umrechnung erzwingt.
|
||||
///
|
||||
/// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die
|
||||
@@ -42,7 +112,7 @@ public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable {
|
||||
public var forcesTranscode: Bool {
|
||||
switch self {
|
||||
case .automatic, .original: false
|
||||
case .fullHD, .hd, .low: true
|
||||
case .limited, .audioOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +127,7 @@ public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable {
|
||||
case .original:
|
||||
// Keine Grenze und kein Umrechnen: ausdrücklich das Original.
|
||||
PlaybackPolicy(allowsTranscoding: false, allowsFallbackEngine: true, maximumBitrate: nil)
|
||||
case .fullHD, .hd, .low:
|
||||
case .limited, .audioOnly:
|
||||
// Ohne Ersatzmotor, damit die Entscheidung zwangsläufig beim
|
||||
// Transcoder landet — sonst spielte VLC die Datei direkt ab und
|
||||
// die gewählte Stufe bliebe wirkungslos.
|
||||
@@ -68,4 +138,17 @@ public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind.
|
||||
///
|
||||
/// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und
|
||||
/// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen.
|
||||
public static func available(forSourceHeight height: Int?) -> [PlayerQuality] {
|
||||
guard let height, height > 0 else { return allCases }
|
||||
let fitting = ladder.filter { $0.height <= height }
|
||||
// Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer
|
||||
// sehr kleinen Quelle nur „Original".
|
||||
let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting
|
||||
return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ extension PlexServer {
|
||||
|
||||
/// Schaltet einen Sender ein.
|
||||
///
|
||||
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
|
||||
/// Live-TV kommt immer als umgerechneter Stream: der Tuner liefert einen
|
||||
/// Transportstrom, den kein iPhone direkt abspielt.
|
||||
///
|
||||
/// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen
|
||||
@@ -675,7 +675,7 @@ extension PlexServer {
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Belegt einen Tuner über den Empfänger und liefert den Strom.
|
||||
/// Belegt einen Tuner über den Empfänger und liefert den Stream.
|
||||
///
|
||||
/// Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag,
|
||||
/// und **dort** steht die abspielbare Teil-Adresse — nicht im Raster und
|
||||
@@ -746,7 +746,7 @@ extension PlexServer {
|
||||
|
||||
// Abwarten, bis der Empfänger abgestimmt hat. Er meldet
|
||||
// `status: inprogress` und `currentSize: 0` — es gibt in dem Moment
|
||||
// noch keinen Strom, und jede Frage danach musste scheitern. Ein
|
||||
// noch keinen Stream, und jede Frage danach musste scheitern. Ein
|
||||
// Tuner ist ein Stück Hardware; er braucht Sekunden, keine
|
||||
// Millisekunden.
|
||||
if let operation = Self.grabOperationKey(in: data) {
|
||||
@@ -861,7 +861,7 @@ extension PlexServer {
|
||||
///
|
||||
/// Höchstens zwölf Sekunden. Länger zu warten hieße, den Nutzer vor einem
|
||||
/// schwarzen Bild sitzen zu lassen; gar nicht zu warten hieß bisher, nach
|
||||
/// einem Strom zu fragen, den es noch nicht gibt.
|
||||
/// einem Stream zu fragen, den es noch nicht gibt.
|
||||
private func waitForGrab(atKey key: String, limit: Int = 12) async -> String {
|
||||
for second in 1...limit {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
@@ -1180,7 +1180,7 @@ extension PlexServer {
|
||||
return keys.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
/// Fragt den Server, ob er diesen Strom zulässt — und warum nicht.
|
||||
/// Fragt den Server, ob er diesen Stream zulässt — und warum nicht.
|
||||
public func liveTVVerdict(for url: URL) async -> LiveTVVerdict {
|
||||
// Dieselben Angaben, nur an den Entscheider statt an den Transcoder.
|
||||
// Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die
|
||||
@@ -1450,7 +1450,7 @@ public struct LiveTuneResult: Sendable {
|
||||
public let trace: [ChannelProbe]
|
||||
}
|
||||
|
||||
/// Was der Server zu einem Strom sagt.
|
||||
/// Was der Server zu einem Stream sagt.
|
||||
public enum LiveTVVerdict: Sendable, Equatable {
|
||||
case allowed
|
||||
/// Ausdrücklich abgelehnt, mit Begründung.
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
extension PlexServer {
|
||||
// MARK: - Transcoding
|
||||
|
||||
/// Die Adresse des HLS-Stroms 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
|
||||
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
|
||||
@@ -74,7 +74,7 @@ extension PlexServer {
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// Baut die Adresse eines Stroms, den der Server schon bereitstellt.
|
||||
/// Baut die Adresse eines Streams, den der Server schon bereitstellt.
|
||||
///
|
||||
/// Für Live-TV. Token und Gerätekennung müssen in die Adresse, weil sie
|
||||
/// der Systemplayer abruft und der keine Kopfzeilen mitschickt.
|
||||
@@ -128,7 +128,7 @@ extension PlexServer {
|
||||
///
|
||||
/// Der Server richtet seine Entscheidung danach aus. Die Angaben stammen
|
||||
/// aus `DeviceCapabilities` — wären sie großzügiger als das, was iOS
|
||||
/// wirklich kann, schickte Plex einen Strom, der hier stumm bliebe oder
|
||||
/// wirklich kann, schickte Plex einen Stream, der hier stumm bliebe oder
|
||||
/// schwarz.
|
||||
private nonisolated var clientProfileItems: [URLQueryItem] {
|
||||
[
|
||||
@@ -282,7 +282,7 @@ extension PlexServer {
|
||||
/// Die Adresse für einen heruntergerechneten Download.
|
||||
///
|
||||
/// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den
|
||||
/// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Strom
|
||||
/// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Stream
|
||||
/// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der
|
||||
/// umständlich und schlecht dokumentiert ist.
|
||||
///
|
||||
@@ -290,7 +290,7 @@ extension PlexServer {
|
||||
/// bevor die Einstellung angeboten wird:
|
||||
/// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen
|
||||
/// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren.
|
||||
/// 2. Ob die entstehende Datei durchsuchbar ist. Ein Strom, der von vorn
|
||||
/// 2. Ob die entstehende Datei durchsuchbar ist. Ein Stream, der von vorn
|
||||
/// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht
|
||||
/// springen.
|
||||
///
|
||||
@@ -321,7 +321,7 @@ extension PlexServer {
|
||||
|
||||
components.queryItems = request.queryItems
|
||||
// Überschreibt `protocol=hls` aus der Anfrage: für eine Datei
|
||||
// brauchen wir einen fortlaufenden Strom, keine Segmentliste.
|
||||
// brauchen wir einen fortlaufenden Stream, keine Segmentliste.
|
||||
.filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" }
|
||||
+ [
|
||||
URLQueryItem(name: "protocol", value: "http"),
|
||||
|
||||
@@ -84,7 +84,7 @@ public struct TranscodeRequest: Sendable, Equatable {
|
||||
// Ohne fastSeek muss der Transcoder beim Springen von vorn
|
||||
// beginnen — bei einem Zweistünder dauert das ewig.
|
||||
URLQueryItem(name: "fastSeek", value: "1"),
|
||||
// 0 heißt: wir fragen hier ausdrücklich nach einem Strom. Ob
|
||||
// 0 heißt: wir fragen hier ausdrücklich nach einem Stream. Ob
|
||||
// DirectPlay möglich wäre, hat der PlaybackDecider schon geprüft.
|
||||
URLQueryItem(name: "directPlay", value: "0"),
|
||||
// 1 erlaubt das bloße Umpacken, wenn der Bildcodec passt. Das
|
||||
|
||||
@@ -291,7 +291,7 @@ struct PlexGridMedia: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Strom hinter einer Rastersendung.
|
||||
/// Der Stream hinter einer Rastersendung.
|
||||
struct PlexGridPart: Decodable {
|
||||
let key: String?
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user