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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 21:50:39 +02:00
co-authored by Claude Opus 5
parent c2ddc10871
commit 12bb49615b
28 changed files with 666 additions and 262 deletions
+33 -61
View File
@@ -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
}
}