Die Bedienung blieb stehen, weil der Zeiger sich nie bewegt hat
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
983015b9ad
commit
60194c2d2a
@@ -42,22 +42,11 @@ struct PlayerView: View {
|
|||||||
@FocusState private var keyboardFocus: Bool
|
@FocusState private var keyboardFocus: Bool
|
||||||
/// Wann die App zuletzt nach vorn geholt wurde.
|
/// Wann die App zuletzt nach vorn geholt wurde.
|
||||||
@State private var becameActive: ContinuousClock.Instant?
|
@State private var becameActive: ContinuousClock.Instant?
|
||||||
/// Wie gross die Player-Fläche gerade ist — für die Frage, wo das Bild
|
/// Wo der Zeiger zuletzt war — um Bewegung von Neuzeichnen zu trennen.
|
||||||
/// aufhört und das Schwarze anfängt.
|
@State private var lastPointerLocation: CGPoint = .zero
|
||||||
@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
|
|
||||||
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
||||||
private var isMac: Bool { FlixyPlatform.isMac }
|
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.
|
/// Wann der Zeiger zuletzt die Bedienelemente geholt hat.
|
||||||
@State private var lastPointerReveal: Date = .distantPast
|
@State private var lastPointerReveal: Date = .distantPast
|
||||||
|
|
||||||
@@ -69,15 +58,25 @@ struct PlayerView: View {
|
|||||||
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
|
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
|
||||||
// Zweck der Stufe.
|
// Zweck der Stufe.
|
||||||
if model.showsVideo {
|
if model.showsVideo {
|
||||||
VideoSurface(
|
// **Die Bildfläche ist genau so gross wie das Bild.**
|
||||||
engine: model.engine,
|
//
|
||||||
surface: model.surface,
|
// Vorher deckte sie das ganze Fenster ab, und die Ebene
|
||||||
isActive: playback.presentation == .full
|
// 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()
|
.ignoresSafeArea()
|
||||||
.onGeometryChange(for: CGRect.self) {
|
.overlay {
|
||||||
$0.frame(in: .named(Self.playerSpace))
|
surface(for: model)
|
||||||
} action: { videoFrame = $0 }
|
.contentShape(.rect)
|
||||||
|
.onTapGesture { primaryTap() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Unter** den Bedienelementen, nicht darüber. Als Überzug
|
// **Unter** den Bedienelementen, nicht darüber. Als Überzug
|
||||||
@@ -204,14 +203,15 @@ struct PlayerView: View {
|
|||||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||||
.landscapeWhileVisible(settings.locksLandscape)
|
.landscapeWhileVisible(settings.locksLandscape)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
// Für alles außerhalb der Sprungflächen. Mit Ort, weil es einen
|
// Für alles ausserhalb von Bild und Sprungflächen — also den
|
||||||
// Unterschied macht, **wo** geklickt wurde — siehe `tap(at:)`.
|
// schwarzen Rand. Ein Film füllt das Fenster fast nie ganz aus, und
|
||||||
.onGeometryChange(for: CGSize.self) { $0.size } action: { playerSize = $0 }
|
// die Balken gehören nicht zum Film: dort zu klicken ist die
|
||||||
// **Ein benannter Raum, kein `.local`.** Gemessen wird die Bildfläche,
|
// natürliche Geste für „zeig mir die Bedienung".
|
||||||
// getippt wird auf den Player — zwei verschiedene Ansichten. Nur in
|
//
|
||||||
// einem gemeinsamen Raum sind die beiden Zahlen vergleichbar.
|
// **Ohne Ort.** Wo geklickt wurde, spielt keine Rolle mehr: die
|
||||||
.coordinateSpace(.named(Self.playerSpace))
|
// Bildfläche fängt ihre eigenen Klicks ab, hier kommt nur noch an,
|
||||||
.onTapGesture(coordinateSpace: .named(Self.playerSpace)) { tap(at: $0) }
|
// was daneben ging.
|
||||||
|
.onTapGesture { toggleControls() }
|
||||||
.onReceive(
|
.onReceive(
|
||||||
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
|
||||||
) { _ in
|
) { _ in
|
||||||
@@ -240,8 +240,8 @@ struct PlayerView: View {
|
|||||||
.onKeyPress(.leftArrow) { onMac { seek(by: -Double(settings.rewindSeconds)) } }
|
.onKeyPress(.leftArrow) { onMac { seek(by: -Double(settings.rewindSeconds)) } }
|
||||||
.onKeyPress(.rightArrow) { onMac { seek(by: Double(settings.forwardSeconds)) } }
|
.onKeyPress(.rightArrow) { onMac { seek(by: Double(settings.forwardSeconds)) } }
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
guard isMac, case .active = phase else { return }
|
guard isMac, case .active(let ort) = phase else { return }
|
||||||
pointerMoved()
|
pointerMoved(to: ort)
|
||||||
}
|
}
|
||||||
// **Die Umgebung wird den Blättern mitgegeben, nicht vererbt.**
|
// **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 {
|
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
|
||||||
if case .playing = model.phase { return true }
|
if case .playing = model.phase { return true }
|
||||||
return false
|
return false
|
||||||
@@ -708,81 +730,6 @@ struct PlayerView: View {
|
|||||||
/// **Mit dem Finger nicht.** Dort gibt es keine Bewegung ohne Berührung:
|
/// **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,
|
/// ein Tipp, der sofort anhält, nähme den einzigen Weg zu Scrubber,
|
||||||
/// Tonspur und Qualität.
|
/// 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() {
|
private func primaryTap() {
|
||||||
guard isMac else {
|
guard isMac else {
|
||||||
toggleControls()
|
toggleControls()
|
||||||
@@ -844,7 +791,18 @@ struct PlayerView: View {
|
|||||||
/// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen
|
/// hundertmal je Sekunde. Die Ausblendfrist jedes Mal neu aufzusetzen
|
||||||
/// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu
|
/// hieße, hundertmal je Sekunde eine Aufgabe abzubrechen und eine neue zu
|
||||||
/// starten. Fünfmal reicht vollkommen.
|
/// 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
|
let now = Date.now
|
||||||
guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return }
|
guard !showsControls || now.timeIntervalSince(lastPointerReveal) > 0.2 else { return }
|
||||||
lastPointerReveal = now
|
lastPointerReveal = now
|
||||||
|
|||||||
Reference in New Issue
Block a user