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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user