From 22f824b000bd38463c055adc7cab859ee962d05c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 29 Sep 2026 17:32:50 +0200 Subject: [PATCH] =?UTF-8?q?Die=20R=C3=A4nder=20kommen=20jetzt=20von=20der?= =?UTF-8?q?=20Stelle,=20die=20sie=20kennt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mein voriger Versuch war an der Quelle falsch, nicht an der Rechnung. **Was ich gebaut hatte:** die Sicherheitsränder in `RootView` messen und über die Umgebung an den Player reichen. Gemessen in einer Ansicht, angewendet in einer anderen, über eine Drehung hinweg — drei Gelegenheiten, daneben zu liegen. Im Simulator ging es gut, auf dem Gerät nicht: dort standen im Querformat links 75 und rechts 28 Punkt Rand, und der Untertitel wurde von der Bildschirmkante abgeschnitten. **Warum SwiftUI hier nicht hilft.** Der Sicherheitsrand ist dort kein Wert, den man erfragt, sondern einer, den man verbraucht: wer ihn respektiert, sieht null, und wer ihn mit `ignoresSafeArea` überschreitet, sieht danach ebenfalls null. Beide Auswege habe ich durchgespielt und beide gemessen. Nimmt man der Ansicht das `ignoresSafeArea`, sitzen die Knöpfe richtig — aber die Bildfläche verliert es mit, und das Bild ist im Querformat um die Notch-Breite schmaler. Schiebt man einen `GeometryReader` dazwischen, passiert dasselbe. **Das Fenster weiß es immer**, in der richtigen Ausrichtung und in dem Moment, in dem gefragt wird. `ScreenSafeArea` ist die eine Stelle in dieser App, die so fragt, und die eine, an der es keinen SwiftUI-Weg gibt. Nachgeholt wird der Wert bei jeder Änderung der Fläche — das ist genau der Moment des Drehens. Nachgemessen im Simulator gegen die Jellyfin-Attrappe: im Querformat meldet das Fenster 0/62/62/20, und damit stehen Kopfzeile, Schieberegler und Zeitangaben vollständig im sichtbaren Bereich, während das Bild bis an die Kanten reicht. Co-Authored-By: Claude Opus 5 --- Flixy/Views/Navigation/RootView.swift | 24 +++----- Flixy/Views/Player/PlayerView.swift | 50 ++++++++++------ Flixy/Views/Player/ScreenSafeArea.swift | 58 +++++++++++++++++++ .../Sources/FlixyDesign/ScreenInsets.swift | 28 --------- 4 files changed, 98 insertions(+), 62 deletions(-) create mode 100644 Flixy/Views/Player/ScreenSafeArea.swift delete mode 100644 Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index e09ab48..3395ac1 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -73,11 +73,6 @@ struct RootView: View { /// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb /// wäre er längst null. @State private var statusBarHeight: CGFloat = 0 - /// Die vollen Sicherheitsränder des Bildschirms. - /// - /// Hier gemessen und nicht im Player: der bekommt `ignoresSafeArea()` und - /// sieht danach überall null. Siehe `flixyScreenInsets`. - @State private var screenInsets = EdgeInsets() var body: some View { Group { @@ -121,12 +116,9 @@ struct RootView: View { // Hier und nicht weiter innen: ab dem `HStack` wird der obere // Sicherheitsrand ignoriert, damit der Inhalt bis an den // Bildschirmrand reicht. Wer danach misst, bekommt null. - .onGeometryChange(for: EdgeInsets.self) { proxy in - proxy.safeAreaInsets - } action: { neue in - screenInsets = neue - statusBarHeight = neue.top - } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.safeAreaInsets.top + } action: { statusBarHeight = $0 } // Hier statt in der Scene: eine View wertet Beobachtungsänderungen // zuverlässig neu aus, ein Scene-Body nicht. .flixyAccent(theme) @@ -152,6 +144,12 @@ struct RootView: View { // ist, und der Übergang ist ruhig. .overlay { if playback.presentation == .full, let player { + // **Bildfüllend, und der Player passt drinnen selbst auf.** + // + // Ohne diese Zeile verliert die Bildfläche ihr + // `ignoresSafeArea` mit — das Bild wäre im Querformat um die + // Notch-Breite schmaler. Wo der Schirm aufhört, holt sich der + // Player deshalb aus dem Fenster; siehe `ScreenSafeArea`. PlayerView(model: player) .ignoresSafeArea() .transition(.move(edge: .bottom)) @@ -167,10 +165,6 @@ struct RootView: View { // bleiben, die es auf dem neuen Server gar nicht gibt. if newPhase == .ready, case .library = selection { selection = .home } } - // **Ganz am Ende der Kette.** Die Umgebung fliesst von aussen nach - // innen: stünde die Zeile vor `.overlay`, umschlösse das Overlay sie - // und der Player bekäme den Wert nicht. Hier umschliesst sie beides. - .flixyScreenInsets(screenInsets) .onChange(of: sizeClass) { _, _ in // Beim Drehen oder Teilen des Fensters wechselt die Form der // Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 557872d..36211ae 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -22,12 +22,6 @@ struct PlayerView: View { @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(\.flixyAccent) private var accent - /// Die Sicherheitsränder des Bildschirms. - /// - /// **Nicht hier gemessen, sondern gereicht.** Diese Ansicht bekommt - /// `ignoresSafeArea()` von der Wurzel und sieht danach überall null; - /// gemessen wird eine Ebene weiter aussen. Siehe `flixyScreenInsets`. - @Environment(\.flixyScreenInsets) private var screenInsets /// Woran sich Quer- und Hochformat unterscheiden lassen. /// /// Auf jedem iPhone ist das Querformat senkrecht kompakt und das @@ -43,6 +37,18 @@ struct PlayerView: View { /// gewöhnlichen Fensters. private static let titleBarHeight: CGFloat = 28 + /// Was der Bildschirm gerade verdeckt. + /// + /// **Aus dem Fenster geholt, nicht von SwiftUI.** Diese Ansicht ist + /// bildfüllend — sie muss es sein, sonst wäre das Bild im Querformat um + /// die Notch-Breite schmaler — und eine bildfüllende Ansicht bekommt von + /// SwiftUI keine Auskunft mehr darüber, wo der Schirm aufhört. Siehe + /// `ScreenSafeArea`. + /// + /// Neu geholt wird er immer dann, wenn sich die Fläche ändert: das ist + /// genau der Moment des Drehens, und genau dann stimmen die alten Werte + /// nicht mehr. + @State private var safeArea = EdgeInsets() @State private var showsControls = true @State private var trackPicker: TrackPickerSheet.Kind? @State private var showsQualityChoice = false @@ -207,11 +213,11 @@ struct PlayerView: View { .opacity(zeigt ? 1 : 0) .allowsHitTesting(zeigt) .padding(FlixySpacing.loose) - // Siehe `controls(for:)`: die Kopfzeile liegt sonst hinter - // der Dynamic Island, und der Zurück-Pfeil ist tot. - .padding(.top, screenInsets.top) - .padding(.leading, screenInsets.leading) - .padding(.trailing, screenInsets.trailing) + // Siehe `controls(for:)`: die Kopfzeile lag sonst hinter + // der Dynamic Island, und der Zurück-Pfeil war tot. + .padding(.top, safeArea.top) + .padding(.leading, safeArea.leading) + .padding(.trailing, safeArea.trailing) // **Unter der Fensterleiste beginnen.** // // Der Player deckt das ganze Fenster ab, und auf dem Mac @@ -230,6 +236,12 @@ struct PlayerView: View { // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { _ in + safeArea = ScreenSafeArea.current + } + .onAppear { safeArea = ScreenSafeArea.current } .animation(.easeInOut(duration: 0.2), value: model.isBuffering) // **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine // Bewegung und soll der Hand entgegenkommen; das Ausblenden ist ein @@ -346,10 +358,10 @@ struct PlayerView: View { // **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt, // wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen // soll. Ersetzte eines das andere, klebte der Schieberegler im - // Querformat am Home-Indikator — genau dort stand er vorher. - .padding(.leading, screenInsets.leading) - .padding(.trailing, screenInsets.trailing) - .padding(.bottom, screenInsets.bottom) + // Querformat am Home-Indikator. + .padding(.leading, safeArea.leading) + .padding(.trailing, safeArea.trailing) + .padding(.bottom, safeArea.bottom) .background { // Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen // Szenen lesbar bleibt, wenig genug, dass das Bild nicht @@ -669,11 +681,11 @@ struct PlayerView: View { } } .padding(.horizontal, FlixySpacing.section) - .padding(.leading, screenInsets.leading) - .padding(.trailing, screenInsets.trailing) + .padding(.leading, safeArea.leading) + .padding(.trailing, safeArea.trailing) // Über dem Schieberegler, damit sich beides nicht überdeckt, wenn // die Bedienelemente eingeblendet sind. - .padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + screenInsets.bottom) + .padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + safeArea.bottom) } .animation(.snappy, value: model.activeMarker?.id) .animation(.snappy, value: model.hasReachedEnd) @@ -825,7 +837,7 @@ struct PlayerView: View { // **Nur im Füllmodus.** Eingepasst liegen unten ohnehin schwarze // Balken, in denen der Untertitel Platz hat; dort etwas // auszusparen nähme Bild, ohne etwas zu retten. - bottomInset: model.videoFit == .fill ? screenInsets.bottom : 0 + bottomInset: model.videoFit == .fill ? safeArea.bottom : 0 ) // **Im Füllmodus kein Seitenverhältnis.** Die Fläche soll den Schirm diff --git a/Flixy/Views/Player/ScreenSafeArea.swift b/Flixy/Views/Player/ScreenSafeArea.swift new file mode 100644 index 0000000..54340f1 --- /dev/null +++ b/Flixy/Views/Player/ScreenSafeArea.swift @@ -0,0 +1,58 @@ +import SwiftUI +import UIKit + +/// Wo der Bildschirm aufhört — für die Ansichten, die absichtlich darüber +/// hinauslaufen. +/// +/// **Warum nicht aus SwiftUI.** Der Sicherheitsrand ist in SwiftUI kein Wert, +/// den man erfragt, sondern ein Rand, den man verbraucht: wer ihn respektiert, +/// sieht ihn als null, und wer ihn mit `ignoresSafeArea` überschreitet, sieht +/// ihn danach ebenfalls als null. Ein `GeometryReader` dazwischenzuschieben +/// hilft nicht, er nimmt der Bildfläche das `ignoresSafeArea` gleich mit — und +/// dann ist das Bild seitlich um die Notch-Breite kleiner. +/// +/// Der Versuch, ihn eine Ebene höher zu messen und hineinzureichen, hat es +/// auch nicht behoben: gemessen in einer Ansicht, angewendet in einer anderen, +/// über eine Drehung hinweg. Auf dem Gerät kamen dabei die Werte der falschen +/// Ausrichtung an. +/// +/// **Das Fenster weiß es immer.** Es kennt seine Ausrichtung, es kennt die +/// Notch, und es antwortet in dem Moment, in dem gefragt wird. Das ist die +/// einzige Stelle in dieser App, die so fragt — und genau die eine, an der es +/// keinen SwiftUI-Weg gibt. +/// +/// Gebraucht wird es für die Untertitel im Füllmodus: die Bildebene reicht +/// dort über die untere Kante hinaus, und AVFoundation zeichnet die Schrift an +/// ihren unteren Rand. +@MainActor +enum ScreenSafeArea { + /// Was der Bildschirm gerade verdeckt — Notch, Dynamic Island, + /// Home-Indikator, gerundete Ecken. + /// + /// **Jedes Mal frisch gefragt, nie gemerkt.** Beim Drehen ändert sich + /// jeder dieser Werte, und ein gemerkter wäre genau dann falsch, wenn er + /// gebraucht wird. + static var current: EdgeInsets { + let i = windowInsets + // `left`/`right` und nicht `leading`/`trailing`: UIKit denkt in + // Bildschirmseiten, SwiftUI in Leserichtung. Bei einer Sprache, die + // von rechts nach links läuft, wären sie vertauscht — deshalb hier + // ausdrücklich in die Leserichtung übersetzt. + let vonLinks = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.keyWindow?.effectiveUserInterfaceLayoutDirection != .rightToLeft + return EdgeInsets( + top: i.top, + leading: vonLinks ? i.left : i.right, + bottom: i.bottom, + trailing: vonLinks ? i.right : i.left + ) + } + + private static var windowInsets: UIEdgeInsets { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .keyWindow?.safeAreaInsets ?? .zero + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift b/Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift deleted file mode 100644 index 4c14f17..0000000 --- a/Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -/// Die Sicherheitsränder des Bildschirms — gemessen, wo es sie noch gibt. -/// -/// **Warum das überhaupt nötig ist.** Der Player deckt das ganze Fenster ab -/// und bekommt dafür `ignoresSafeArea()`. Innerhalb dieser Ansicht sind die -/// Sicherheitsränder danach **null**: SwiftUI hat sie aufgebraucht, und wer -/// weiter innen danach fragt, bekommt eine ehrliche Antwort auf die falsche -/// Frage. Die Bedienelemente sassen dadurch im Home-Indikator und die -/// Kopfzeile hinter der Dynamic Island. -/// -/// Gemessen wird deshalb weiter aussen — dort, wo die Ränder noch gelten — -/// und über die Umgebung nach innen gereicht. Dieselbe Bauart wie -/// `flixyAccent`: ein Wert, den die Wurzel kennt und alle anderen lesen. -/// -/// Nicht zu verwechseln mit `safeAreaPadding`: das würde den Player wieder -/// verkleinern. Hier bleibt das Bild bildfüllend, und nur die Schrift darauf -/// rückt ein. -extension EnvironmentValues { - @Entry public var flixyScreenInsets: EdgeInsets = EdgeInsets() -} - -extension View { - /// Reicht gemessene Sicherheitsränder nach innen. - public func flixyScreenInsets(_ insets: EdgeInsets) -> some View { - environment(\.flixyScreenInsets, insets) - } -}