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
@@ -2566,6 +2566,7 @@
|
||||
}
|
||||
},
|
||||
"player.stalled": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -2665,6 +2666,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.audioOnly": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nur Ton"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio only"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.audioOnly.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.automatic": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2682,36 +2715,82 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.fullHD": {
|
||||
"extractionState": "stale",
|
||||
"quality.current": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Full HD · 1080p"
|
||||
"value": "Läuft gerade"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Full HD · 1080p"
|
||||
"value": "Currently playing"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.hd": {
|
||||
"extractionState": "stale",
|
||||
"quality.current.bitrate": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "HD · 720p"
|
||||
"value": "Bitrate"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "HD · 720p"
|
||||
"value": "Bitrate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.current.dropped": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Verworfene Bilder"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dropped frames"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.current.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Gemessen, nicht gewünscht: die Stufe sagt, was erlaubt ist — hier steht, was ankommt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Measured, not requested: the setting caps the rate, this is what arrives."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.current.resolution": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auflösung"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2733,6 +2812,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.kilobits %lld": {
|
||||
"comment": "Platzhalter: Kilobit je Sekunde",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld kbps"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld kbps"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.low": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2750,23 +2846,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.lowRes": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sparsam · 480p"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Data saver · 480p"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.medium": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2784,6 +2863,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.megabits %@": {
|
||||
"comment": "Platzhalter: Megabit je Sekunde",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ Mbps"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ Mbps"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.minimal": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
||||
@@ -53,7 +53,7 @@ final class PlaybackCoordinator {
|
||||
/// Der Unterschied zwischen „klein" und „gar nicht" ist der Kern der
|
||||
/// Sache: verkleinern darf die Wiedergabe **nicht** anhalten. Vorher gab
|
||||
/// es diese Unterscheidung nicht — der Player war da oder weg, und mit
|
||||
/// ihm der Motor, der Transcoder und beim Live-TV der belegte Tuner.
|
||||
/// ihm die Engine, der Transcoder und beim Live-TV der belegte Tuner.
|
||||
enum Presentation: Equatable {
|
||||
case hidden
|
||||
case mini
|
||||
@@ -120,7 +120,7 @@ final class PlaybackCoordinator {
|
||||
/// Beendet die Wiedergabe wirklich.
|
||||
///
|
||||
/// Die einzige Stelle, an der das passiert. Alles andere — verkleinern,
|
||||
/// vergrößern, Bild-in-Bild — lässt den Motor am Leben.
|
||||
/// vergrößern, Bild-in-Bild — lässt die Engine am Leben.
|
||||
func close() {
|
||||
request = nil
|
||||
presentation = .hidden
|
||||
|
||||
@@ -51,7 +51,7 @@ final class PlaybackDiagnostics {
|
||||
///
|
||||
/// Der Abstand ist die eigentliche Auskunft: steht zwischen „Quelle
|
||||
/// gewählt" und „spielt" eine Minute, liegt es an der Leitung; kommt
|
||||
/// „spielt" gar nicht, am Motor.
|
||||
/// „spielt" gar nicht, am Engine.
|
||||
func note(_ step: String) {
|
||||
guard var attempt = lastAttempt else { return }
|
||||
let elapsed = Date().timeIntervalSince(attempt.startedAt)
|
||||
|
||||
@@ -226,7 +226,7 @@ struct LiveTVView: View {
|
||||
defer { isTuning = nil }
|
||||
|
||||
// Die laufende Sendung mitgeben: über ihre Adresse beim
|
||||
// Programmanbieter führt der direkte Weg zum Strom.
|
||||
// Programmanbieter führt der direkte Weg zum Stream.
|
||||
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
||||
let policy = settings.policy(for: network)
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ struct RootView: View {
|
||||
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
|
||||
/// abgestimmt, bevor es hier ankommt.
|
||||
private func handOver(to request: PlaybackRequest?) async {
|
||||
// Zuerst und ohne Warten: den alten Motor stilllegen. Das Aufräumen am
|
||||
// Server darf dauern, das Abschalten nicht — läuft der alte Motor
|
||||
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
|
||||
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
|
||||
// weiter, während der neue startet, streiten sich zwei um dieselbe
|
||||
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
|
||||
// als neue Wiedergabe, die stumm blieb.
|
||||
|
||||
@@ -17,23 +17,26 @@ struct MiniPlayer: View {
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
/// Die Ecke bleibt über Starts hinweg erhalten. Wer die Kachel einmal
|
||||
/// nach links unten gezogen hat, will sie beim nächsten Mal dort haben.
|
||||
@AppStorage("miniPlayer.corner") private var storedCorner = MiniPlayerPlacement.Corner.bottomTrailing.rawValue
|
||||
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
|
||||
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
|
||||
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
|
||||
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
|
||||
/// Stelle, statt unten herauszufallen.
|
||||
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
|
||||
|
||||
/// Verschiebung während des Ziehens, noch nicht eingerastet.
|
||||
@State private var drag: CGSize = .zero
|
||||
@State private var showsControls = false
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
|
||||
private var corner: MiniPlayerPlacement.Corner {
|
||||
MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing
|
||||
private var side: MiniPlayerPlacement.Side {
|
||||
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
|
||||
}
|
||||
|
||||
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
|
||||
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
|
||||
/// Änderung während des Ziehens am Griff, noch nicht übernommen.
|
||||
@State private var widthChange: CGFloat = 0
|
||||
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
|
||||
@State private var zoom: CGFloat = 1
|
||||
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
|
||||
///
|
||||
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
|
||||
@@ -49,7 +52,7 @@ struct MiniPlayer: View {
|
||||
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
|
||||
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
||||
let upper = max(160, min(bounds.width * 0.75, 560))
|
||||
let width = min(max(chosen + widthChange, 140), upper)
|
||||
let width = min(max(chosen * zoom, 140), upper)
|
||||
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
|
||||
}
|
||||
|
||||
@@ -57,18 +60,21 @@ struct MiniPlayer: View {
|
||||
GeometryReader { geometry in
|
||||
let tileSize = tileSize(in: geometry.size)
|
||||
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
|
||||
let resting = placement.center(for: corner)
|
||||
let resting = placement.center(side: side, verticalFraction: storedFraction)
|
||||
let position = placement.clamped(
|
||||
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
||||
)
|
||||
|
||||
tile(corner: corner)
|
||||
tile
|
||||
.frame(width: tileSize.width, height: tileSize.height)
|
||||
.onChange(of: tileSize.width, initial: true) { _, width in
|
||||
lastWidth = width
|
||||
}
|
||||
.position(position)
|
||||
.gesture(dragGesture(in: placement, from: resting))
|
||||
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
|
||||
// Loslassen greift — beides passiert mit denselben Fingern.
|
||||
.simultaneousGesture(zoomGesture(in: geometry.size))
|
||||
// Nur während des Ziehens ohne Animation, sonst würde die
|
||||
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
|
||||
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
|
||||
@@ -76,7 +82,7 @@ struct MiniPlayer: View {
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private func tile(corner: MiniPlayerPlacement.Corner) -> some View {
|
||||
private var tile: some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
@@ -88,7 +94,6 @@ struct MiniPlayer: View {
|
||||
|
||||
if showsControls {
|
||||
controls.transition(.opacity)
|
||||
resizeHandle(corner: corner).transition(.opacity)
|
||||
}
|
||||
|
||||
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
|
||||
@@ -156,58 +161,22 @@ struct MiniPlayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Griff zum Größerziehen.
|
||||
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
|
||||
///
|
||||
/// Er sitzt in der Ecke, die zur Bildschirmmitte zeigt — dort ist er
|
||||
/// erreichbar, egal in welcher Ecke die Kachel klebt. Am äußeren Rand
|
||||
/// läge er halb außerhalb des Daumens.
|
||||
private func resizeHandle(corner: MiniPlayerPlacement.Corner) -> some View {
|
||||
// Über `overlay(alignment:)` gesetzt, nicht über gedrehte Stapel:
|
||||
// eine Drehung auf dem Überzug kippt die gesamte Trefferfläche mit,
|
||||
// und der Griff lag dann dort, wo man nicht hinfasst. Genau daran war
|
||||
// das Ziehen vorher wirkungslos.
|
||||
Color.clear.overlay(alignment: handleAlignment(for: corner)) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
// Nur das Symbol dreht sich, damit der Pfeil in die Richtung
|
||||
// zeigt, in die es tatsächlich größer wird.
|
||||
.rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90))
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(.rect)
|
||||
// Vorrang vor dem Verschieben der Kachel, das am selben Ort
|
||||
// hängt — sonst gewinnt das Ziehen und die Kachel wandert.
|
||||
.highPriorityGesture(resizeGesture(corner: corner))
|
||||
// Und ein Tippen darf hier nicht ins Vollbild führen.
|
||||
.onTapGesture { reveal() }
|
||||
.accessibilityLabel(Text("miniPlayer.resize"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Ecke der Kachel, die zur Bildschirmmitte zeigt.
|
||||
private func handleAlignment(for corner: MiniPlayerPlacement.Corner) -> Alignment {
|
||||
switch corner {
|
||||
case .topLeading: .bottomTrailing
|
||||
case .topTrailing: .bottomLeading
|
||||
case .bottomLeading: .topTrailing
|
||||
case .bottomTrailing: .topLeading
|
||||
}
|
||||
}
|
||||
|
||||
private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture {
|
||||
DragGesture(minimumDistance: 2)
|
||||
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
|
||||
/// und stritt sich mit dem Verschieben um dieselbe Geste.
|
||||
private func zoomGesture(in bounds: CGSize) -> some Gesture {
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
hideTask?.cancel()
|
||||
// Klebt die Kachel rechts, wächst sie beim Ziehen nach links.
|
||||
widthChange = corner.isLeading ? value.translation.width : -value.translation.width
|
||||
zoom = value.magnification
|
||||
}
|
||||
.onEnded { _ in
|
||||
// Die **begrenzte** Breite merken, nicht die gezogene: eine
|
||||
// einmal zu weit gezogene Kachel bliebe sonst für immer zu
|
||||
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
|
||||
// groß, auch auf einem kleineren Bildschirm.
|
||||
storedWidth = Double(lastWidth)
|
||||
widthChange = 0
|
||||
reveal()
|
||||
zoom = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,13 +196,16 @@ struct MiniPlayer: View {
|
||||
}
|
||||
.onEnded { value in
|
||||
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
|
||||
// dem Finger: sonst springt eine Kachel, die man an der Kante
|
||||
// angefasst hat, in die falsche Hälfte.
|
||||
let center = CGPoint(
|
||||
// dem Finger: sonst springt eine Kachel, die man am Rand
|
||||
// angefasst hat, auf die falsche Seite.
|
||||
let center = placement.clamped(center: CGPoint(
|
||||
x: resting.x + value.predictedEndTranslation.width,
|
||||
y: resting.y + value.predictedEndTranslation.height
|
||||
)
|
||||
storedCorner = placement.nearestCorner(toCenter: center).rawValue
|
||||
))
|
||||
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
|
||||
// wohin man sie gezogen hat.
|
||||
storedSide = placement.nearestSide(toCenter: center).rawValue
|
||||
storedFraction = placement.verticalFraction(forCenter: center)
|
||||
drag = .zero
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import PlexKit
|
||||
/// Führt einen Abspielwunsch aus.
|
||||
///
|
||||
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
|
||||
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht
|
||||
/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die
|
||||
/// passendie Engine und hält dessen Zustand für die Ansicht bereit. Die Ansicht
|
||||
/// weiß nicht, welche Engine läuft — bis auf die eine Stelle, an der die
|
||||
/// Bildfläche gebaut wird.
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -58,6 +58,17 @@ final class PlayerModel {
|
||||
/// Ob gerade auf eine andere Stufe umgestellt wird.
|
||||
private(set) var isChangingQuality = false
|
||||
|
||||
/// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt.
|
||||
var statistics: PlaybackStatistics? { engine?.statistics }
|
||||
|
||||
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
|
||||
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
|
||||
/// Bild dazu.
|
||||
var sourceHeight: Int? { currentMedia?.height }
|
||||
|
||||
/// Ob das Bild gezeigt werden soll.
|
||||
var showsVideo: Bool { quality.showsVideo }
|
||||
|
||||
/// Die Bildebene des Systemplayers.
|
||||
///
|
||||
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
|
||||
@@ -181,7 +192,7 @@ final class PlayerModel {
|
||||
/// Stellt auf eine andere Qualitätsstufe um.
|
||||
///
|
||||
/// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse
|
||||
/// des Stroms, nicht in einer Einstellung, die sich unterwegs drehen ließe.
|
||||
/// des Streams, nicht in einer Einstellung, die sich unterwegs drehen ließe.
|
||||
/// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der
|
||||
/// Film bei jedem Wechsel von vorn.
|
||||
func select(quality newQuality: PlayerQuality) async {
|
||||
@@ -196,7 +207,7 @@ final class PlayerModel {
|
||||
quality = newQuality
|
||||
|
||||
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
||||
// der Server zwei Ströme gleichzeitig für einen Zuschauer.
|
||||
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
||||
if let transcodeSession {
|
||||
await server.stopTranscode(sessionIdentifier: transcodeSession)
|
||||
self.transcodeSession = nil
|
||||
@@ -228,7 +239,7 @@ final class PlayerModel {
|
||||
/// Wiedergabe aus dem Download-Ordner.
|
||||
///
|
||||
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
|
||||
/// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim
|
||||
/// dem Server lag. Welcher Engine sie öffnen kann, steht im Abzug, der beim
|
||||
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
|
||||
/// Live-TV: die Adresse steht schon fest.
|
||||
///
|
||||
@@ -236,7 +247,7 @@ final class PlayerModel {
|
||||
/// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es
|
||||
/// gibt keine Stelle, an der man ihn später fortsetzen könnte.
|
||||
private func startLiveStream(url: URL) async {
|
||||
diagnostics.note("Live-Strom öffnen")
|
||||
diagnostics.note("Live-Stream öffnen")
|
||||
isLive = true
|
||||
title = request.title
|
||||
// Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es
|
||||
@@ -309,11 +320,11 @@ final class PlayerModel {
|
||||
observe(engine)
|
||||
|
||||
do {
|
||||
diagnostics.note("Motor öffnet Quelle")
|
||||
diagnostics.note("Engine öffnet Quelle")
|
||||
try await engine.load(source)
|
||||
engine.play()
|
||||
phase = .playing
|
||||
diagnostics.note("Motor gestartet")
|
||||
diagnostics.note("Engine gestartet")
|
||||
// Weder offline noch live gibt es einen Fortschritt zu melden.
|
||||
if !isOffline, !isLive { startReporting() }
|
||||
} catch {
|
||||
@@ -322,7 +333,7 @@ final class PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom.
|
||||
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream.
|
||||
private func source(
|
||||
for decision: PlaybackDecision,
|
||||
item: PlexItem,
|
||||
@@ -366,7 +377,7 @@ final class PlayerModel {
|
||||
|
||||
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
|
||||
///
|
||||
/// Ein Live-Strom kann sterben, ohne dass der Motor es merkt — der Tuner
|
||||
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
|
||||
/// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten,
|
||||
/// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum
|
||||
/// nächsten Antippen, und niemand weiß, ob noch etwas kommt.
|
||||
@@ -379,8 +390,8 @@ final class PlayerModel {
|
||||
guard !Task.isCancelled, let self, self.isBuffering else { return }
|
||||
|
||||
diagnostics.note("Seit 40 s keine Daten — abgebrochen")
|
||||
// Den Motor gleich stilllegen: er würde sonst weiter an einem
|
||||
// toten Strom ziehen, und bei Live-TV bliebe der Tuner belegt.
|
||||
// Den Engine gleich stilllegen: er würde sonst weiter an einem
|
||||
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
|
||||
silence()
|
||||
phase = .failed(message: String(localized: "player.stalled"))
|
||||
Task { await self.releaseOnServer() }
|
||||
@@ -451,11 +462,11 @@ final class PlayerModel {
|
||||
selectedSubtitleTrack = track
|
||||
}
|
||||
|
||||
/// Legt den Motor **sofort** still. Kein Netzwerk, kein Warten.
|
||||
/// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten.
|
||||
///
|
||||
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
|
||||
/// stand der Motor erst nach drei Netzwerkaufrufen still. Wurde in der
|
||||
/// Zwischenzeit etwas Neues gestartet, liefen zwei Motoren gleichzeitig,
|
||||
/// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der
|
||||
/// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig,
|
||||
/// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung
|
||||
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
|
||||
/// die App im Hintergrund war —, und das Neue blieb stumm.
|
||||
@@ -574,10 +585,10 @@ final class PlayerModel {
|
||||
return item.media[request.mediaIndex]
|
||||
}
|
||||
|
||||
/// Hängt die gehaltene Bildebene an den aktuellen Motor.
|
||||
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
|
||||
///
|
||||
/// Nach **jedem** Motorwechsel nötig — also auch nach einem
|
||||
/// Qualitätswechsel, der den Motor neu baut. Ohne das zeigt die Ebene
|
||||
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
|
||||
/// Qualitätswechsel, der die Engine neu baut. Ohne das zeigt die Ebene
|
||||
/// weiter auf einen abgeräumten Player und bleibt schwarz.
|
||||
private func attachSurface(to engine: any VideoPlayerEngine) {
|
||||
guard let avEngine = engine as? AVPlayerEngine else { return }
|
||||
@@ -628,14 +639,14 @@ final class PlayerModel {
|
||||
isBuffering = state == .buffering || state == .loading
|
||||
watchBuffering(isBuffering)
|
||||
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
||||
// abzulesen, ob der Motor hängt oder die Leitung.
|
||||
// abzulesen, ob die Engine hängt oder die Leitung.
|
||||
diagnostics.note("Zustand: \(state)")
|
||||
if case .failed(let failure) = state {
|
||||
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
|
||||
// Der Grund gehört auf den Bildschirm, nicht nur ins
|
||||
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
|
||||
// ist eine Sackgasse — es sagt weder, ob der Server
|
||||
// ablehnt, noch ob der Motor das Format nicht mag.
|
||||
// ablehnt, noch ob die Engine das Format nicht mag.
|
||||
phase = .failed(message: [
|
||||
String(localized: "player.playbackFailed"),
|
||||
String(describing: failure),
|
||||
|
||||
@@ -27,14 +27,21 @@ struct PlayerView: View {
|
||||
@State private var showsQualityChoice = false
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
@State private var scrubbing: Double?
|
||||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||||
@State private var seekHint: Double?
|
||||
@State private var hintTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
|
||||
// Zweck der Stufe.
|
||||
if model.showsVideo {
|
||||
VideoSurface(engine: model.engine, surface: model.surface)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
switch model.phase {
|
||||
case .preparing:
|
||||
@@ -83,21 +90,20 @@ struct PlayerView: View {
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.landscapeWhileVisible()
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { toggleControls() }
|
||||
// Zwei Hälften: links zurück, rechts vor. Ein einzelnes Tippen zeigt
|
||||
// weiterhin die Bedienelemente — die Reihenfolge entscheidet, sonst
|
||||
// verschluckt das einfache Tippen das doppelte.
|
||||
.overlay {
|
||||
HStack(spacing: 0) {
|
||||
seekZone(seconds: -10, symbol: "gobackward.10")
|
||||
seekZone(seconds: 10, symbol: "goforward.10")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsTrackPicker) {
|
||||
TrackPickerSheet(model: model)
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text("player.quality"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(PlayerQuality.allCases) { option in
|
||||
Button(qualityChoiceLabel(option, current: model.quality)) {
|
||||
Task { await model.select(quality: option) }
|
||||
}
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
.sheet(isPresented: $showsQualityChoice) {
|
||||
QualitySheet(model: model)
|
||||
}
|
||||
.onChange(of: showsQualityChoice) { _, isShown in
|
||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||
@@ -364,13 +370,45 @@ struct PlayerView: View {
|
||||
.padding(FlixySpacing.section)
|
||||
}
|
||||
|
||||
/// Die gewählte Stufe bekommt einen Haken.
|
||||
/// Eine Bildschirmhälfte zum Springen.
|
||||
///
|
||||
/// Ein `Picker` im Dialog wäre eleganter, aber `confirmationDialog` zeigt
|
||||
/// nur Knöpfe — und die Auswahl muss trotzdem erkennbar sein.
|
||||
private func qualityChoiceLabel(_ option: PlayerQuality, current: PlayerQuality) -> String {
|
||||
let name = String(localized: option.localizedName)
|
||||
return option == current ? "\(name) ✓" : name
|
||||
/// Bewusst ohne sichtbare Trennung: die Fläche soll sich nicht aufdrängen.
|
||||
/// Der kurze Hinweis beim Doppeltippen sagt genug.
|
||||
private func seekZone(seconds: Double, symbol: String) -> some View {
|
||||
Color.clear
|
||||
.contentShape(.rect)
|
||||
.overlay {
|
||||
if seekHint == seconds {
|
||||
Label(
|
||||
seconds < 0 ? "player.back10" : "player.forward10",
|
||||
systemImage: symbol
|
||||
)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.white)
|
||||
.padding(FlixySpacing.section)
|
||||
.background(.black.opacity(0.35), in: .circle)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
// Das doppelte zuerst: sonst gewinnt das einfache und man käme nie
|
||||
// zum Springen.
|
||||
.onTapGesture(count: 2) { seek(by: seconds) }
|
||||
.onTapGesture { toggleControls() }
|
||||
}
|
||||
|
||||
private func seek(by seconds: Double) {
|
||||
guard let current = model.duration != nil || model.isLive ? model.currentTime : nil
|
||||
else { return }
|
||||
model.seek(to: current + .seconds(seconds))
|
||||
|
||||
withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds }
|
||||
hintTask?.cancel()
|
||||
hintTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(600))
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.2)) { seekHint = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
||||
@@ -416,15 +454,3 @@ extension Duration {
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerQuality {
|
||||
var localizedName: String.LocalizationValue {
|
||||
switch self {
|
||||
case .automatic: "quality.automatic"
|
||||
case .original: "quality.original"
|
||||
case .fullHD: "quality.fullHD"
|
||||
case .hd: "quality.hd"
|
||||
case .low: "quality.lowRes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
|
||||
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
||||
///
|
||||
/// Ein Blatt statt eines Dialogs: die Leiter hat je nach Quelle bis zu
|
||||
/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen
|
||||
/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben,
|
||||
/// was gerade über die Leitung kommt.
|
||||
struct QualitySheet: View {
|
||||
let model: PlayerModel
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var options: [PlayerQuality] {
|
||||
PlayerQuality.available(forSourceHeight: model.sourceHeight)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
current
|
||||
|
||||
Section {
|
||||
row(for: .automatic)
|
||||
row(for: .original)
|
||||
}
|
||||
|
||||
ForEach(groupedHeights, id: \.self) { height in
|
||||
Section("\(height)p") {
|
||||
ForEach(rungs(height: height), id: \.self) { rung in
|
||||
row(for: .limited(rung))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
row(for: .audioOnly)
|
||||
} footer: {
|
||||
Text("quality.audioOnly.hint")
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text("player.quality"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
/// Was gerade läuft — gemessen, nicht gewünscht.
|
||||
@ViewBuilder
|
||||
private var current: some View {
|
||||
if let statistics = model.statistics {
|
||||
Section {
|
||||
if let observed = statistics.observedBitrate {
|
||||
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
|
||||
}
|
||||
if let resolution = statistics.resolution {
|
||||
LabeledContent("quality.current.resolution", value: resolution)
|
||||
}
|
||||
if let dropped = statistics.droppedFrames, dropped > 0 {
|
||||
LabeledContent("quality.current.dropped", value: "\(dropped)")
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var groupedHeights: [Int] {
|
||||
var seen = Set<Int>()
|
||||
return options.compactMap { option -> Int? in
|
||||
guard case .limited(let rung) = option else { return nil }
|
||||
return seen.insert(rung.height).inserted ? rung.height : nil
|
||||
}
|
||||
}
|
||||
|
||||
private func rungs(height: Int) -> [PlayerQuality.Rung] {
|
||||
options.compactMap { option in
|
||||
guard case .limited(let rung) = option, rung.height == height else { return nil }
|
||||
return rung
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for option: PlayerQuality) -> some View {
|
||||
Button {
|
||||
Task { await model.select(quality: option) }
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label(for: option))
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
Spacer()
|
||||
if option == model.quality {
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func label(for option: PlayerQuality) -> String {
|
||||
switch option {
|
||||
case .automatic: String(localized: "quality.automatic")
|
||||
case .original: String(localized: "quality.original")
|
||||
case .audioOnly: String(localized: "quality.audioOnly")
|
||||
case .limited(let rung): bitrateText(rung.bitrate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle.
|
||||
///
|
||||
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"
|
||||
/// schlechter als „64 kbps".
|
||||
private func bitrateText(_ kilobits: Int) -> String {
|
||||
guard kilobits >= 1_000 else {
|
||||
return String(localized: "quality.kilobits \(kilobits)")
|
||||
}
|
||||
let megabits = Double(kilobits) / 1_000
|
||||
let rounded = megabits.rounded() == megabits
|
||||
? String(Int(megabits))
|
||||
: String(format: "%.1f", megabits)
|
||||
return String(localized: "quality.megabits \(rounded)")
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import SwiftUI
|
||||
import FlixyPlayback
|
||||
import FlixyVLC
|
||||
|
||||
/// Die Bildfläche des laufenden Motors.
|
||||
/// Die Bildfläche des laufendie Engine.
|
||||
///
|
||||
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
|
||||
/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
|
||||
/// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
|
||||
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
|
||||
/// `UIView`.
|
||||
struct VideoSurface: View {
|
||||
|
||||
@@ -4,7 +4,7 @@ import PlexKit
|
||||
|
||||
/// Was beim letzten Abspielversuch passiert ist.
|
||||
///
|
||||
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welcher Motor gewählt
|
||||
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welche Engine gewählt
|
||||
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
||||
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
||||
struct DiagnosticsView: View {
|
||||
|
||||
@@ -40,7 +40,7 @@ let package = Package(
|
||||
.target(name: "FlixyDesign", resources: [.process("Localizable.xcstrings")]),
|
||||
.testTarget(name: "FlixyDesignTests", dependencies: ["FlixyDesign"]),
|
||||
|
||||
// Die Wiedergabe-Abstraktion samt AVPlayer-Motor und der Entscheidung
|
||||
// Die Wiedergabe-Abstraktion samt AVPlayer-Engine und der Entscheidung
|
||||
// DirectPlay/Transcode. VLCKit steckt bewusst NICHT hier drin, sondern
|
||||
// in FlixyPlaybackVLC — sonst zöge jeder Testlauf ein 920 MB großes
|
||||
// xcframework hinter sich her.
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -13,42 +13,55 @@ struct MiniPlayerPlacementTests {
|
||||
inset: 12
|
||||
)
|
||||
|
||||
@Test("Jede Bildschirmhälfte führt in ihre Ecke")
|
||||
func picksTheNearestCorner() {
|
||||
#expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 50)) == .topLeading)
|
||||
#expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 50)) == .topTrailing)
|
||||
#expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 800)) == .bottomLeading)
|
||||
#expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 800)) == .bottomTrailing)
|
||||
@Test("Die nähere Seite gewinnt")
|
||||
func picksTheNearestSide() {
|
||||
#expect(placement.nearestSide(toCenter: CGPoint(x: 50, y: 400)) == .leading)
|
||||
#expect(placement.nearestSide(toCenter: CGPoint(x: 340, y: 400)) == .trailing)
|
||||
}
|
||||
|
||||
@Test("Genau auf der Mitte gewinnt unten rechts")
|
||||
func decidesTheTie() {
|
||||
// Willkürlich, aber festgelegt: ohne eine Regel für den Gleichstand
|
||||
// hinge das Ergebnis an Fließkomma-Zufall, und dieselbe Geste führte
|
||||
// mal hierhin, mal dorthin.
|
||||
let center = CGPoint(x: 195, y: 422)
|
||||
@Test("Die Höhe bleibt frei, nicht auf Ecken festgelegt")
|
||||
func keepsTheVerticalPosition() {
|
||||
// Der Kern des Umbaus: vier Ecken sind schnell gebaut, aber im Weg.
|
||||
let middle = placement.center(side: .trailing, verticalFraction: 0.5)
|
||||
let top = placement.center(side: .trailing, verticalFraction: 0)
|
||||
let bottom = placement.center(side: .trailing, verticalFraction: 1)
|
||||
|
||||
#expect(placement.nearestCorner(toCenter: center) == .bottomTrailing)
|
||||
#expect(top.y < middle.y)
|
||||
#expect(middle.y < bottom.y)
|
||||
#expect(top.x == middle.x && middle.x == bottom.x)
|
||||
}
|
||||
|
||||
@Test("In jeder Ecke bleibt der Sicherheitsabstand")
|
||||
@Test("Anteil und Mittelpunkt rechnen ineinander um")
|
||||
func roundTripsTheFraction() {
|
||||
for fraction in [0.0, 0.25, 0.5, 0.9, 1.0] {
|
||||
let center = placement.center(side: .leading, verticalFraction: fraction)
|
||||
let back = placement.verticalFraction(forCenter: center)
|
||||
|
||||
#expect(abs(back - fraction) < 0.0001)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Der Anteil bleibt zwischen null und eins")
|
||||
func clampsTheFraction() {
|
||||
// Sonst wandert die Kachel beim Drehen aus dem Bild: der Anteil wird
|
||||
// gespeichert, und ein Wert über eins landete unterhalb des Randes.
|
||||
#expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: -9_000)) == 0)
|
||||
#expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: 9_000)) == 1)
|
||||
#expect(placement.center(side: .leading, verticalFraction: 5).y <= 844 - 12)
|
||||
}
|
||||
|
||||
@Test("An jeder Seite bleibt der Sicherheitsabstand")
|
||||
func keepsTheInset() {
|
||||
for corner in MiniPlayerPlacement.Corner.allCases {
|
||||
let start = placement.origin(for: corner)
|
||||
for side in MiniPlayerPlacement.Side.allCases {
|
||||
for fraction in [0.0, 0.5, 1.0] {
|
||||
let center = placement.center(side: side, verticalFraction: fraction)
|
||||
|
||||
#expect(start.x >= placement.inset)
|
||||
#expect(start.y >= placement.inset)
|
||||
#expect(start.x + placement.tile.width <= 390 - placement.inset)
|
||||
#expect(start.y + placement.tile.height <= 844 - placement.inset)
|
||||
#expect(center.x - 90 >= 12)
|
||||
#expect(center.x + 90 <= 390 - 12)
|
||||
#expect(center.y - 50.5 >= 12)
|
||||
#expect(center.y + 50.5 <= 844 - 12)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Unten rechts sitzt die Kachel auch wirklich unten rechts")
|
||||
func computesTheFarCorner() {
|
||||
let start = placement.origin(for: .bottomTrailing)
|
||||
|
||||
#expect(start.x == 198.0)
|
||||
#expect(start.y == 731.0)
|
||||
}
|
||||
|
||||
@Test("Beim Ziehen rutscht nichts über den Rand")
|
||||
@@ -82,7 +95,7 @@ struct MiniPlayerPlacementTests {
|
||||
inset: 12
|
||||
)
|
||||
|
||||
#expect(narrow.origin(for: .bottomTrailing) == CGPoint(x: 12, y: 12))
|
||||
#expect(narrow.clamped(center: CGPoint(x: 0, y: 0)) == CGPoint(x: 102, y: 62.5))
|
||||
#expect(narrow.center(side: .trailing, verticalFraction: 1) == CGPoint(x: 102, y: 62.5))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ struct PlaybackDeciderTests {
|
||||
#expect(decision.method == .directPlay(engine: .avPlayer))
|
||||
}
|
||||
|
||||
@Test("Unanalysierte Dateien gehen an den toleranteren Motor")
|
||||
@Test("Unanalysierte Dateien gehen an den toleranteren Engine")
|
||||
func unknownStreamsFallBackToVLC() {
|
||||
let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: [])
|
||||
let media = PlexMedia(id: 100, container: "mp4", parts: [part])
|
||||
|
||||
@@ -331,7 +331,7 @@ struct LiveTVGuideTests {
|
||||
#expect(path == "/livetv/sessions/xyz/stream")
|
||||
}
|
||||
|
||||
@Test("Der Tuner wird per POST belegt, und die Antwort trägt den Strom")
|
||||
@Test("Der Tuner wird per POST belegt, und die Antwort trägt den Stream")
|
||||
func tunesThroughDVRWithPost() async throws {
|
||||
// Nachgelesen im Quelltext von Plex' eigenem Client:
|
||||
// POST /livetv/dvrs/{dvr}/channels/{kennung}/tune
|
||||
|
||||
Reference in New Issue
Block a user