Die Ränder kommen jetzt von der Stelle, die sie kennt

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 17:32:50 +02:00
co-authored by Claude Opus 5
parent 6326c8c24c
commit 22f824b000
4 changed files with 98 additions and 62 deletions
+9 -15
View File
@@ -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
+31 -19
View File
@@ -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
+58
View File
@@ -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
}
}
@@ -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)
}
}