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
+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