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? 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) } } } }