diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index c39f8c0..0438aa2 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -42,22 +42,11 @@ struct PlayerView: View { @FocusState private var keyboardFocus: Bool /// Wann die App zuletzt nach vorn geholt wurde. @State private var becameActive: ContinuousClock.Instant? - /// Wie gross die Player-Fläche gerade ist — für die Frage, wo das Bild - /// aufhört und das Schwarze anfängt. - @State private var playerSize: CGSize = .zero - /// Wo die Bildfläche im Player liegt — gemessen, nicht angenommen. - /// - /// **Der Unterschied zur Fenstergrösse.** Die Bildfläche hat - /// `ignoresSafeArea()`, der Player selbst nicht. Auf dem Mac liegt oben - /// die Fensterleiste als Einzug: die beiden Flächen sind gegeneinander - /// verschoben. Wer den schwarzen Rand aus der Fenstergrösse ausrechnet, - /// rechnet ihn um genau diesen Einzug falsch — und trifft daneben. - @State private var videoFrame: CGRect = .zero + /// Wo der Zeiger zuletzt war — um Bewegung von Neuzeichnen zu trennen. + @State private var lastPointerLocation: CGPoint = .zero /// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt. private var isMac: Bool { FlixyPlatform.isMac } - /// Der gemeinsame Raum für Messen und Tippen. - private static let playerSpace = "flixy.player" /// Wann der Zeiger zuletzt die Bedienelemente geholt hat. @State private var lastPointerReveal: Date = .distantPast @@ -69,15 +58,25 @@ struct PlayerView: View { // Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe // Zweck der Stufe. if model.showsVideo { - VideoSurface( - engine: model.engine, - surface: model.surface, - isActive: playback.presentation == .full - ) + // **Die Bildfläche ist genau so gross wie das Bild.** + // + // Vorher deckte sie das ganze Fenster ab, und die Ebene + // liess oben und unten schwarz. Damit gehörten die Balken + // zur Bildfläche, und für SwiftUI war ein Klick darauf ein + // Klick aufs Bild. Wo das Bild aufhört, mussten wir + // ausrechnen — aus Fenstergrösse, Sicherheitsrändern und + // Seitenverhältnis. Diese Rechnung lag zweimal daneben. + // + // Jetzt rechnet das Layout. Der schwarze Rand ist schlicht + // der Hintergrund dahinter, und wer ihn anklickt, klickt + // nicht aufs Bild — das muss niemand mehr feststellen. + Color.clear .ignoresSafeArea() - .onGeometryChange(for: CGRect.self) { - $0.frame(in: .named(Self.playerSpace)) - } action: { videoFrame = $0 } + .overlay { + surface(for: model) + .contentShape(.rect) + .onTapGesture { primaryTap() } + } } // **Unter** den Bedienelementen, nicht darüber. Als Überzug @@ -204,14 +203,15 @@ struct PlayerView: View { .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible(settings.locksLandscape) .contentShape(.rect) - // Für alles außerhalb der Sprungflächen. Mit Ort, weil es einen - // Unterschied macht, **wo** geklickt wurde — siehe `tap(at:)`. - .onGeometryChange(for: CGSize.self) { $0.size } action: { playerSize = $0 } - // **Ein benannter Raum, kein `.local`.** Gemessen wird die Bildfläche, - // getippt wird auf den Player — zwei verschiedene Ansichten. Nur in - // einem gemeinsamen Raum sind die beiden Zahlen vergleichbar. - .coordinateSpace(.named(Self.playerSpace)) - .onTapGesture(coordinateSpace: .named(Self.playerSpace)) { tap(at: $0) } + // Für alles ausserhalb von Bild und Sprungflächen — also den + // schwarzen Rand. Ein Film füllt das Fenster fast nie ganz aus, und + // die Balken gehören nicht zum Film: dort zu klicken ist die + // natürliche Geste für „zeig mir die Bedienung". + // + // **Ohne Ort.** Wo geklickt wurde, spielt keine Rolle mehr: die + // Bildfläche fängt ihre eigenen Klicks ab, hier kommt nur noch an, + // was daneben ging. + .onTapGesture { toggleControls() } .onReceive( NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) ) { _ in @@ -240,8 +240,8 @@ struct PlayerView: View { .onKeyPress(.leftArrow) { onMac { seek(by: -Double(settings.rewindSeconds)) } } .onKeyPress(.rightArrow) { onMac { seek(by: Double(settings.forwardSeconds)) } } .onContinuousHover { phase in - guard isMac, case .active = phase else { return } - pointerMoved() + guard isMac, case .active(let ort) = phase else { return } + pointerMoved(to: ort) } // **Die Umgebung wird den Blättern mitgegeben, nicht vererbt.** // @@ -691,6 +691,28 @@ struct PlayerView: View { } } + /// Die Bildfläche, auf das Seitenverhältnis zugeschnitten. + /// + /// Solange nichts ankommt, ist keines bekannt — dann füllt sie alles. Das + /// ist richtig so: ohne Bild gibt es auch keinen schwarzen Rand daneben, + /// den man von ihm unterscheiden müsste. + @ViewBuilder + private func surface(for model: PlayerModel) -> some View { + let fläche = VideoSurface( + engine: model.engine, + surface: model.surface, + isActive: playback.presentation == .full + ) + + if let breite = model.statistics?.width, + let höhe = model.statistics?.height, + breite > 0, höhe > 0 { + fläche.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit) + } else { + fläche + } + } + private func isPlayingPhase(_ model: PlayerModel) -> Bool { if case .playing = model.phase { return true } return false @@ -708,81 +730,6 @@ struct PlayerView: View { /// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung: /// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber, /// Tonspur und Qualität. - /// Ein Klick — und die Frage, worauf. - /// - /// **Auf das Bild** heißt anhalten: das ist der Griff, den man aus jedem - /// Player kennt. - /// - /// **Ins Schwarze daneben** heißt ein- und ausblenden. Ein Film füllt das - /// Fenster fast nie ganz aus — oben und unten oder links und rechts - /// bleiben Balken, und die gehören nicht zum Film. Dort zu klicken ist - /// die natürliche Geste für „zeig mir die Bedienung" und war bisher - /// dasselbe wie ein Griff mitten ins Bild. - private func tap(at point: CGPoint) { - guard isMac, !videoRect.contains(point) else { - primaryTap() - return - } - toggleControls() - } - - /// Wo das Bild tatsächlich liegt. - /// - /// **Zuerst die Ebene fragen.** `AVPlayerLayer.videoRect` ist die Fläche, - /// auf der wirklich Bild steht — vom Player selbst ausgerechnet, aus dem, - /// was gerade ankommt, und einschliesslich dessen, was die Füllart damit - /// macht. Jede eigene Rechnung ist daneben eine Nachbildung, die - /// irgendwann abweicht. - /// - /// **Dann die eigene Rechnung.** Solange die Ebene noch nichts zeigt — - /// und bei VLC, das keine hat — bleibt das Seitenverhältnis aus den - /// Messwerten. Es wird auf `videoFrame` angewandt, nicht auf die - /// Fenstergrösse: die beiden unterscheiden sich um den Einzug der - /// Fensterleiste, und **das** war der Fehler, wegen dem ein Klick in den - /// schwarzen Rand bisher angehalten hat statt einzublenden. - /// - /// Ohne beides die ganze Fläche: dann verhält es sich wie früher, statt - /// aus Unkenntnis überall das Falsche zu tun. - private var videoRect: CGRect { - let ganzes = CGRect(origin: .zero, size: playerSize) - let fläche = videoFrame.isEmpty ? ganzes : videoFrame - guard fläche.width > 0, fläche.height > 0 else { return ganzes } - - // Die Ebene liegt in der Bildfläche; ihre Angabe ist relativ zu deren - // Ecke und muss dorthin verschoben werden. - let ausDerEbene = model.surface.videoRect - if ausDerEbene.width > 1, ausDerEbene.height > 1 { - return ausDerEbene.offsetBy(dx: fläche.minX, dy: fläche.minY) - } - - guard let breite = model.statistics?.width, - let höhe = model.statistics?.height, - breite > 0, höhe > 0 - else { return ganzes } - - let verhältnisBild = CGFloat(breite) / CGFloat(höhe) - let verhältnisFläche = fläche.width / fläche.height - - if verhältnisBild > verhältnisFläche { - // Breiter als die Fläche: Balken oben und unten. - let sichtbar = fläche.width / verhältnisBild - return CGRect( - x: fläche.minX, - y: fläche.minY + (fläche.height - sichtbar) / 2, - width: fläche.width, - height: sichtbar - ) - } - // Höher als die Fläche: Balken links und rechts. - let sichtbar = fläche.height * verhältnisBild - return CGRect( - x: fläche.minX + (fläche.width - sichtbar) / 2, - y: fläche.minY, - width: sichtbar, - height: fläche.height - ) - } - private func primaryTap() { guard isMac else { toggleControls() @@ -844,7 +791,18 @@ struct PlayerView: View { /// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen /// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu /// starten. Fünfmal reicht vollkommen. - private func pointerMoved() { + private func pointerMoved(to ort: CGPoint) { + // **Gleiche Stelle heisst: der Zeiger hat sich nicht bewegt.** + // + // Die Meldung kommt nicht nur bei Bewegung, sondern auch bei jedem + // Neuzeichnen — und seit die Bedienung dauerhaft montiert ist, zeichnet + // der Scrubber im Sekundentakt neu. Ohne diese Prüfung setzte die + // Ausblendfrist sich endlos selbst zurück: die Bedienung blieb für + // immer stehen, und die Überblendung, die nie stattfand, sah aus wie + // eine kaputte Animation. Sie war nie das Problem. + guard ort != lastPointerLocation else { return } + lastPointerLocation = ort + let now = Date.now guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return } lastPointerReveal = now