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:
co-authored by
Claude Opus 5
parent
6326c8c24c
commit
22f824b000
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user