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 /// 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 /// Verschiebung während des Ziehens, noch nicht eingerastet. @State private var drag: CGSize = .zero @State private var showsControls = false @State private var hideTask: Task? private var corner: MiniPlayerPlacement.Corner { MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing } /// 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 /// 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 + widthChange, 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(for: corner) let position = placement.clamped( center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height) ) tile(corner: corner) .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)) // 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 func tile(corner: MiniPlayerPlacement.Corner) -> some View { ZStack { Color.black VideoSurface(engine: model.engine, surface: model.surface) if model.isBuffering { ProgressView().tint(.white) } if showsControls { controls.transition(.opacity) resizeHandle(corner: corner).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() } } } /// Der Griff zum Größerziehen. /// /// 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) .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 } .onEnded { _ in // Die **begrenzte** Breite merken, nicht die gezogene: eine // einmal zu weit gezogene Kachel bliebe sonst für immer zu // groß, auch auf einem kleineren Bildschirm. storedWidth = Double(lastWidth) widthChange = 0 reveal() } } 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 an der Kante // angefasst hat, in die falsche Hälfte. let center = CGPoint( x: resting.x + value.predictedEndTranslation.width, y: resting.y + value.predictedEndTranslation.height ) storedCorner = placement.nearestCorner(toCenter: center).rawValue 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) } } } }