Die Seitenleiste war zu hoch — und der Player ging deshalb hinaus

Dreimal habe ich an den Rändern geschraubt, und dreimal war es nicht das.
Der Fehler sass in der Navigation und zeigte sich im Player.

**Gemessen statt vermutet.** Auf einem iPhone Pro Max im Querformat:

    Fenster  956×440, Rand 0,62,62,20  →  erwartet 832×420
    Wurzel   832×558,67 @62,−69,33     →  138,67 zu hoch

Der Wurzelbaum ist zu gross für den Vorschlag und wird darin zentriert;
(420 − 558,67) / 2 ergibt exakt −69,33. Der Player liegt als Überlagerung
darin und erbt den Kasten — oben fielen neunundsechzig Punkte heraus,
unten neunundvierzig. Genau dort sitzen Kopfzeile und Zeitangaben.

**Die Ursache:** `FlixySidebar` war eine `VStack` ohne Bildlaufansicht,
mit dem Kommentar „die Einträge sind wenige und passen". Auf einem iPad
und im Hochformat stimmt das. Ein grosses iPhone ist quer
vierhundertvierzig Punkte hoch, und die Leiste braucht mit Kopf, Gruppen,
Trenner und Kontozeile gut fünfhundertdreissig. Eine `VStack` schrumpft
dann nicht — sie überragt, der `HStack` nimmt das Maximum, und alles
darüber wächst mit.

Deshalb war es nur dort zu sehen: nur auf einem grossen iPhone ist die
Anordnung quer `regular`, und nur dann gibt es überhaupt eine
Seitenleiste. Auf einem 17 Pro ist quer `compact` — dort stimmte jede
Messung, und genau deshalb konnte ich es dreimal nicht nachstellen.

Jetzt scrollt die Leiste, wenn sie muss (`basedOnSize`). Wo die Einträge
passen, sieht man keinen Unterschied. Nachgemessen: Wurzel 832×420,
Rahmen des Players 956×440 @0,0 — deckungsgleich mit dem Fenster.

**Mitgenommen:** `statusBarHeight` kam aus `proxy.safeAreaInsets.top` und
ging über `topInset` in einen `safeAreaInset` am selben Baum zurück. Das
Layout lief dadurch in Runden auf die richtige Grösse zu, jede Runde
zweiundsechzig Punkte. Der Wert kommt jetzt aus dem Fenster, das ihn ohne
Rückkopplung kennt — eine Runde statt drei.

`ScreenSafeArea` liegt dafür nicht mehr beim Player, sondern bei den
Modellen: es beantwortet jetzt für zwei Stellen dieselbe Frage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-30 17:15:50 +02:00
co-authored by Claude Opus 5
parent 7bcef03c25
commit 5f635c699a
4 changed files with 53 additions and 58 deletions
@@ -105,9 +105,25 @@ struct FlixySidebar: View {
VStack(alignment: .leading, spacing: 0) {
header
// Ohne Bildlaufansicht im Ruhezustand: die Einträge sind wenige
// und passen. Beim Bearbeiten braucht es eine Liste — nur die
// bringt das Ziehen zum Umsortieren mit.
// **Doch mit Bildlaufansicht.** Hier stand „die Einträge sind
// wenige und passen" — und das stimmte auf einem iPad und im
// Hochformat. Auf einem grossen iPhone **quer** ist der
// Bildschirm vierhundertvierzig Punkte hoch, und die Leiste
// braucht mit Kopf, Gruppen, Trenner und Kontozeile gut
// fünfhundertdreissig.
//
// Eine `VStack` schrumpft dann nicht, sie **überragt**: der
// `HStack` daneben nimmt das Maximum, und der ganze Wurzelbaum
// wurde hundertachtunddreissig Punkte höher als der Bildschirm.
// Was darin als Überlagerung liegt — der Player — ging oben und
// unten hinaus, und Kopfzeile wie Zeitangaben waren weg. Der
// Fehler sass also in der Navigation und zeigte sich im Player.
//
// `basedOnSize`: es wird nur gescrollt, wenn es nötig ist. Wo die
// Einträge passen, sieht man keinen Unterschied.
//
// Beim Bearbeiten braucht es eine Liste — nur die bringt das
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
if isEditing {
Text("sidebar.editHint")
.font(.flixyCaption)
@@ -137,13 +153,17 @@ struct FlixySidebar: View {
.scrollContentBackground(.hidden)
.environment(\.editMode, .constant(.active))
} else {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(visible(group))
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(visible(group))
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
.scrollBounceBehavior(.basedOnSize)
.scrollIndicators(.never)
}
Spacer(minLength: 0)
+25 -6
View File
@@ -113,12 +113,31 @@ struct RootView: View {
}
.background(Color.flixyCanvas)
.environment(topChrome)
// 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: CGFloat.self) { proxy in
proxy.safeAreaInsets.top
} action: { statusBarHeight = $0 }
// **Aus dem Fenster, nicht aus dem Layout — und das ist der ganze
// Punkt.**
//
// Hier stand `proxy.safeAreaInsets.top`, und daraus wurde eine
// Rückkopplung: der gemessene Rand geht in `topInset`, `topInset`
// geht in einen `safeAreaInset` am Inhalt, und der verändert genau
// den Rand, der gerade gemessen wurde. Das Layout lief danach in
// Runden auf die richtige Grösse zu — jede Runde zweiundsechzig
// Punkte, also genau eine Statusleiste — und blieb auf halbem Weg
// stehen, weil SwiftUI irgendwann aufhört, neu zu rechnen.
//
// Sichtbar war das nur im Querformat auf einem grossen iPhone: dort
// ist die Anordnung `regular`, nur dort gibt es den `safeAreaInset`,
// und nur dort ist der Rand vorher und nachher verschieden. Der
// Wurzelbaum war dadurch hundertachtzehn Punkte höher als der
// Bildschirm und begann neunundsechzig darüber — und der Player, der
// als Überlagerung darin liegt, ging oben und unten hinaus. Genau so
// verschwanden Kopfzeile und Zeitangaben.
//
// Das Fenster kennt seinen Rand ohne Rückkopplung. Die Grösse bleibt
// der Anlass zum Nachfragen, der **Wert** kommt von aussen.
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { _ in statusBarHeight = ScreenSafeArea.current.top }
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
// zuverlässig neu aus, ein Scene-Body nicht.
.flixyAccent(theme)
-37
View File
@@ -49,16 +49,6 @@ struct PlayerView: View {
/// genau der Moment des Drehens, und genau dann stimmen die alten Werte
/// nicht mehr.
@State private var safeArea = EdgeInsets()
#if DEBUG
/// Wie gross der Player gezeichnet wird und wo er anfängt.
///
/// **Die Frage, an der alles hängt.** Ist der Rahmen grösser als der
/// Bildschirm, liegt es nicht an den Rändern — dann wird der Player in
/// einen zu grossen Kasten gelegt und mittig darin ausgerichtet, und
/// oben wie unten fällt gleich viel heraus. Genau so sieht es aus, wenn
/// die Kopfzeile fehlt und die Zeitangaben auch.
@State private var rahmen: CGRect = .zero
#endif
@State private var showsControls = true
@State private var trackPicker: TrackPickerSheet.Kind?
@State private var showsQualityChoice = false
@@ -251,11 +241,6 @@ struct PlayerView: View {
} action: { _ in
safeArea = ScreenSafeArea.current
}
#if DEBUG
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { rahmen = $0 }
#endif
.onAppear { safeArea = ScreenSafeArea.current }
// **Auch beim Zurückholen der App.** Eine Drehung im Hintergrund
// ändert die Fläche, ohne dass `onGeometryChange` hier feuert — die
@@ -379,7 +364,6 @@ struct PlayerView: View {
transport(for: model)
Spacer()
scrubber(for: model)
diagnose(for: model)
}
.padding(FlixySpacing.loose)
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
@@ -404,27 +388,6 @@ struct PlayerView: View {
}
}
/// Was der Player über seinen Platz weiss.
///
/// **Nur im Entwicklerbau, und nur, weil zweimal Raten nicht gereicht
/// hat.** Sitzen die Knöpfe ausserhalb des Bildschirms, gibt es genau
/// drei Möglichkeiten: die Ränder kommen als null an, sie kommen falsch
/// an, oder sie stimmen und der Rahmen ist grösser als der Schirm. Von
/// aussen sehen alle drei gleich aus. Diese Zeile unterscheidet sie.
///
/// Die Schirmgrösse daneben ist dabei der Schlüssel: steht dort etwas
/// anderes als die Fläche, auf der die Knöpfe liegen, liegt es nicht an
/// den Rändern.
@ViewBuilder
private func diagnose(for model: PlayerModel) -> some View {
#if DEBUG
let schirm = ScreenSafeArea.screenSize
Text(verbatim: "SA \(Int(safeArea.top)),\(Int(safeArea.leading)),\(Int(safeArea.trailing)),\(Int(safeArea.bottom)) Rahmen \(Int(rahmen.width))×\(Int(rahmen.height)) @\(Int(rahmen.minX)),\(Int(rahmen.minY)) Schirm \(Int(schirm.width))×\(Int(schirm.height)) \(model.videoFit == .fill ? "füllen" : "einpassen")")
.font(.system(size: 11).monospaced())
.foregroundStyle(.yellow)
#endif
}
private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
-79
View File
@@ -1,79 +0,0 @@
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 = fenster?.effectiveUserInterfaceLayoutDirection != .rightToLeft
return EdgeInsets(
top: i.top,
leading: vonLinks ? i.left : i.right,
bottom: i.bottom,
trailing: vonLinks ? i.right : i.left
)
}
/// Wie gross der Bildschirm gerade ist.
///
/// Nur für die Diagnose: sie beantwortet die Frage, ob der Player grösser
/// gezeichnet wird als der Schirm — und die lässt sich von innen sonst
/// nicht stellen.
static var screenSize: CGSize { fenster?.bounds.size ?? .zero }
private static var windowInsets: UIEdgeInsets { fenster?.safeAreaInsets ?? .zero }
/// Das Fenster, das gerade zählt.
///
/// **Mit Rückfall, und das ist der Punkt.** Zuerst stand hier nur
/// „die Szene, die `foregroundActive` ist" — und genau die gibt es in
/// zwei Momenten nicht: während einer Drehung und kurz nach dem
/// Zurückholen der App. Wer dann fragt, bekommt `nil`, daraus werden
/// Ränder von null, und die bleiben stehen, bis sich die Fläche wieder
/// ändert. Die Knöpfe sitzen dann im Home-Indikator, und zwar dauerhaft,
/// obwohl die Rechnung stimmt.
///
/// Ein Fenster, das die richtigen Ränder kennt, ist in dem Moment
/// trotzdem da — es ist nur nicht „aktiv". Also der Reihe nach: aktiv,
/// sonst sichtbar, sonst irgendeines.
private static var fenster: UIWindow? {
let szenen = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let gewählt = szenen.first { $0.activationState == .foregroundActive }
?? szenen.first { $0.activationState == .foregroundInactive }
?? szenen.first
return gewählt?.keyWindow ?? gewählt?.windows.first { $0.isKeyWindow } ?? gewählt?.windows.first
}
}