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