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 } /// Auf dem iPad darf sie größer sein — dort ist genug Fläche, und bei /// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts. private var tileWidth: CGFloat { sizeClass == .regular ? 280 : 180 } private var tileSize: CGSize { CGSize(width: tileWidth, height: (tileWidth / FlixyAspectRatio.landscape).rounded()) } var body: some View { GeometryReader { geometry in 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 .frame(width: tileSize.width, height: tileSize.height) .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 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() } } } 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) } } } }