From 60194c2d2a0d02ca691bdd19bf4bb638ed2ffa29 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 28 Sep 2026 18:01:27 +0200 Subject: [PATCH] Die Bedienung blieb stehen, weil der Zeiger sich nie bewegt hat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit die Bedienung dauerhaft montiert ist, zeichnet der Scrubber darin im Sekundentakt neu. `onContinuousHover` meldet nicht nur bei Bewegung, sondern auch bei jedem Neuzeichnen — also setzte die Ausblendfrist sich endlos selbst zurück. Die Bedienung blieb für immer stehen, und die Überblendung, die dadurch nie stattfand, sah aus wie eine kaputte Animation. Sie war nie das Problem; das war sie schon beim letzten Anlauf nicht. Jetzt gilt der Zeiger nur als bewegt, wenn er an einer anderen Stelle ist als zuletzt. Und der Klick in den schwarzen Rand: ich habe zweimal ausgerechnet, wo das Bild aufhört, und lag zweimal daneben. Jetzt rechnet das Layout. Die Bildfläche ist auf ihr Seitenverhältnis zugeschnitten und hat ihren eigenen Griff; der schwarze Rand ist schlicht der Hintergrund dahinter. Was am Player ankommt, ist damit per Definition daneben gegangen — es muss niemand mehr feststellen. Damit fallen `videoRect`, `videoFrame`, `playerSize`, der benannte Koordinatenraum und die Abfrage der `AVPlayerLayer` ersatzlos weg. Co-Authored-By: Claude Opus 5 --- Flixy/Views/Player/PlayerView.swift | 172 +++++++++++----------------- 1 file changed, 65 insertions(+), 107 deletions(-) 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