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
@@ -73,11 +73,6 @@ struct RootView: View {
|
|||||||
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
|
/// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb
|
||||||
/// wäre er längst null.
|
/// wäre er längst null.
|
||||||
@State private var statusBarHeight: CGFloat = 0
|
@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 {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -121,12 +116,9 @@ struct RootView: View {
|
|||||||
// Hier und nicht weiter innen: ab dem `HStack` wird der obere
|
// Hier und nicht weiter innen: ab dem `HStack` wird der obere
|
||||||
// Sicherheitsrand ignoriert, damit der Inhalt bis an den
|
// Sicherheitsrand ignoriert, damit der Inhalt bis an den
|
||||||
// Bildschirmrand reicht. Wer danach misst, bekommt null.
|
// Bildschirmrand reicht. Wer danach misst, bekommt null.
|
||||||
.onGeometryChange(for: EdgeInsets.self) { proxy in
|
.onGeometryChange(for: CGFloat.self) { proxy in
|
||||||
proxy.safeAreaInsets
|
proxy.safeAreaInsets.top
|
||||||
} action: { neue in
|
} action: { statusBarHeight = $0 }
|
||||||
screenInsets = neue
|
|
||||||
statusBarHeight = neue.top
|
|
||||||
}
|
|
||||||
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
||||||
// zuverlässig neu aus, ein Scene-Body nicht.
|
// zuverlässig neu aus, ein Scene-Body nicht.
|
||||||
.flixyAccent(theme)
|
.flixyAccent(theme)
|
||||||
@@ -152,6 +144,12 @@ struct RootView: View {
|
|||||||
// ist, und der Übergang ist ruhig.
|
// ist, und der Übergang ist ruhig.
|
||||||
.overlay {
|
.overlay {
|
||||||
if playback.presentation == .full, let player {
|
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)
|
PlayerView(model: player)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
@@ -167,10 +165,6 @@ struct RootView: View {
|
|||||||
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
||||||
if newPhase == .ready, case .library = selection { selection = .home }
|
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
|
.onChange(of: sizeClass) { _, _ in
|
||||||
// Beim Drehen oder Teilen des Fensters wechselt die Form der
|
// Beim Drehen oder Teilen des Fensters wechselt die Form der
|
||||||
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
|
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ struct PlayerView: View {
|
|||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@Environment(\.flixyAccent) private var accent
|
@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.
|
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
||||||
///
|
///
|
||||||
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
||||||
@@ -43,6 +37,18 @@ struct PlayerView: View {
|
|||||||
/// gewöhnlichen Fensters.
|
/// gewöhnlichen Fensters.
|
||||||
private static let titleBarHeight: CGFloat = 28
|
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 showsControls = true
|
||||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||||
@State private var showsQualityChoice = false
|
@State private var showsQualityChoice = false
|
||||||
@@ -207,11 +213,11 @@ struct PlayerView: View {
|
|||||||
.opacity(zeigt ? 1 : 0)
|
.opacity(zeigt ? 1 : 0)
|
||||||
.allowsHitTesting(zeigt)
|
.allowsHitTesting(zeigt)
|
||||||
.padding(FlixySpacing.loose)
|
.padding(FlixySpacing.loose)
|
||||||
// Siehe `controls(for:)`: die Kopfzeile liegt sonst hinter
|
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
|
||||||
// der Dynamic Island, und der Zurück-Pfeil ist tot.
|
// der Dynamic Island, und der Zurück-Pfeil war tot.
|
||||||
.padding(.top, screenInsets.top)
|
.padding(.top, safeArea.top)
|
||||||
.padding(.leading, screenInsets.leading)
|
.padding(.leading, safeArea.leading)
|
||||||
.padding(.trailing, screenInsets.trailing)
|
.padding(.trailing, safeArea.trailing)
|
||||||
// **Unter der Fensterleiste beginnen.**
|
// **Unter der Fensterleiste beginnen.**
|
||||||
//
|
//
|
||||||
// Der Player deckt das ganze Fenster ab, und auf dem Mac
|
// 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.
|
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
||||||
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
||||||
// und im Querformat erst recht nicht.
|
// 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)
|
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
|
||||||
// **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine
|
// **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine
|
||||||
// Bewegung und soll der Hand entgegenkommen; das Ausblenden ist ein
|
// 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,
|
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
||||||
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
||||||
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
||||||
// Querformat am Home-Indikator — genau dort stand er vorher.
|
// Querformat am Home-Indikator.
|
||||||
.padding(.leading, screenInsets.leading)
|
.padding(.leading, safeArea.leading)
|
||||||
.padding(.trailing, screenInsets.trailing)
|
.padding(.trailing, safeArea.trailing)
|
||||||
.padding(.bottom, screenInsets.bottom)
|
.padding(.bottom, safeArea.bottom)
|
||||||
.background {
|
.background {
|
||||||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
||||||
@@ -669,11 +681,11 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.section)
|
.padding(.horizontal, FlixySpacing.section)
|
||||||
.padding(.leading, screenInsets.leading)
|
.padding(.leading, safeArea.leading)
|
||||||
.padding(.trailing, screenInsets.trailing)
|
.padding(.trailing, safeArea.trailing)
|
||||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||||
// die Bedienelemente eingeblendet sind.
|
// 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.activeMarker?.id)
|
||||||
.animation(.snappy, value: model.hasReachedEnd)
|
.animation(.snappy, value: model.hasReachedEnd)
|
||||||
@@ -825,7 +837,7 @@ struct PlayerView: View {
|
|||||||
// **Nur im Füllmodus.** Eingepasst liegen unten ohnehin schwarze
|
// **Nur im Füllmodus.** Eingepasst liegen unten ohnehin schwarze
|
||||||
// Balken, in denen der Untertitel Platz hat; dort etwas
|
// Balken, in denen der Untertitel Platz hat; dort etwas
|
||||||
// auszusparen nähme Bild, ohne etwas zu retten.
|
// 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
|
// **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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user