Files
Flaro/Flixy/Views/Player/MiniPlayer.swift
T
ScarriffleandClaude Opus 5 12bb49615b 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>
2026-09-12 21:50:39 +02:00

239 lines
9.5 KiB
Swift

import SwiftUI
import FlixyDesign
/// Die schwebende Videokachel.
///
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
///
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
struct MiniPlayer: View {
let model: PlayerModel
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass
/// 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 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
/// 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
/// eine einmal zu weit gezogene Kachel bliebe für immer zu groß.
@State private var lastWidth: CGFloat = 0
/// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei
/// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts.
private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 }
private func tileSize(in bounds: CGSize) -> CGSize {
let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth
// 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 * zoom, 140), upper)
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
}
var body: some View {
GeometryReader { geometry in
let tileSize = tileSize(in: geometry.size)
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
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
.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)
}
.transition(.scale(scale: 0.8).combined(with: .opacity))
}
private var tile: some View {
ZStack {
Color.black
VideoSurface(engine: model.engine, surface: model.surface)
if model.isBuffering {
ProgressView().tint(.white)
}
if showsControls {
controls.transition(.opacity)
}
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
// es gibt kein Ende, auf das er zulaufen könnte.
if !model.isLive, let fraction = progress {
VStack {
Spacer()
WatchProgressBar(fraction: fraction)
}
}
}
.clipShape(.rect(cornerRadius: FlixyRadius.card))
.overlay {
RoundedRectangle(cornerRadius: FlixyRadius.card)
.strokeBorder(Color.flixySeparator, lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.contentShape(.rect)
.onTapGesture {
// Antippen zeigt erst die Bedienelemente, dann öffnet es. Ein
// sofortiges Aufklappen bei jeder Berührung wäre lästig, wenn man
// die Kachel nur anfassen will.
if showsControls {
playback.expand()
} else {
reveal()
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(model.title))
}
private var controls: some View {
ZStack {
Color.black.opacity(0.45)
Button {
model.togglePlayPause()
reveal()
} label: {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.title2)
.foregroundStyle(.white)
.padding(FlixySpacing.snug)
.contentShape(.rect)
}
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
VStack {
HStack {
Spacer()
Button {
playback.close()
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(FlixySpacing.snug)
.contentShape(.rect)
}
.accessibilityLabel(Text("miniPlayer.close"))
}
Spacer()
}
}
}
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
///
/// 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()
zoom = value.magnification
}
.onEnded { _ in
// Die **begrenzte** Breite merken, nicht die gezogene: eine
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
// groß, auch auf einem kleineren Bildschirm.
storedWidth = Double(lastWidth)
zoom = 1
}
}
private var progress: Double? {
guard let duration = model.duration, duration.seconds > 0 else { return nil }
return model.currentTime.seconds / duration.seconds
}
private func dragGesture(
in placement: MiniPlayerPlacement,
from resting: CGPoint
) -> some Gesture {
DragGesture()
.onChanged { value in
hideTask?.cancel()
drag = value.translation
}
.onEnded { value in
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
// 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
))
// 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
}
}
/// Zeigt die Bedienelemente und blendet sie nach kurzer Zeit wieder aus.
private func reveal() {
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
hideTask?.cancel()
hideTask = Task {
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.2)) { showsControls = false }
}
}
}
extension View {
/// Legt die schwebende Kachel über diesen Bildschirm.
///
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
overlay {
if let model, isVisible {
MiniPlayer(model: model)
}
}
}
}