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 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, isActive: playback.presentation == .mini ) if model.isBuffering { ProgressView().tint(.white) } corners // 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) // Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen: // die erste holte die Bedienelemente hervor, die zweite sollte // öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte // lag. Vergrößern war damit überhaupt nicht möglich. .onTapGesture { playback.expand() } .accessibilityElement(children: .contain) .accessibilityLabel(Text(model.title)) } /// Die beiden Knöpfe in den oberen Ecken. /// /// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte /// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel /// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein. /// Unten bleibt frei für den Fortschrittsbalken. private var corners: some View { VStack { HStack { tileButton( model.isPlaying ? "pause.fill" : "play.fill", label: model.isPlaying ? "player.pause" : "player.play" ) { model.togglePlayPause() } Spacer() tileButton("xmark", label: "miniPlayer.close") { playback.close() } } Spacer() HStack { Spacer() // Ein eigener Knopf, obwohl auch das Antippen der Fläche // vergrößert. Beim Ziehen und Aufziehen liegen auf derselben // Fläche schon zwei Gesten, und was daneben geht, sieht aus // wie „reagiert nicht". Ein Knopf geht nicht daneben. tileButton( "arrow.up.left.and.arrow.down.right", label: "miniPlayer.expand" ) { playback.expand() } } } .padding(FlixySpacing.tight) // Über dem Fortschrittsbalken, nicht darauf. .padding(.bottom, FlixySpacing.tight) } /// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt. /// /// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf /// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren /// Helligkeit sich jede Sekunde ändert. private func tileButton( _ symbol: String, label: LocalizedStringKey, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: symbol) .font(.caption.weight(.bold)) .foregroundStyle(.white) .frame(width: 26, height: 26) .background(.black.opacity(0.55), in: .circle) } .buttonStyle(.plain) .accessibilityLabel(Text(label)) } /// 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 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 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 } } } 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) } } } }