diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 00a30ac..5a12a4f 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -3555,6 +3555,40 @@ } } }, + "player.fillVideo": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bild füllen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fill screen" + } + } + } + }, + "player.fitVideo": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bild einpassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fit to screen" + } + } + } + }, "player.forgetRememberedTrack": { "extractionState": "manual", "localizations": { @@ -3858,6 +3892,23 @@ } } }, + "player.rotate": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Drehen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rotate" + } + } + } + }, "player.skipCredits": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/OrientationLock.swift b/Flixy/Session/OrientationLock.swift index 176bd47..1e02732 100644 --- a/Flixy/Session/OrientationLock.swift +++ b/Flixy/Session/OrientationLock.swift @@ -23,18 +23,29 @@ final class OrientationLock { } } - /// Dreht ins Querformat und hält es dort. + /// Dreht zum Start der Wiedergabe ins Querformat — und gibt danach frei. + /// + /// **Gedreht wird einmal, gesperrt wird nicht.** Vorher blieb die Maske + /// auf `.landscape` stehen, und damit liess sich im Player nicht mehr ins + /// Hochformat drehen. Ein Film beginnt quer, weil er quer ist; wer danach + /// im Bett auf der Seite liegt, soll drehen dürfen. func lockLandscape() { - guard UIDevice.current.userInterfaceIdiom != .pad else { return } - apply(.landscape, rotatingTo: .landscape) + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + apply(.allButUpsideDown, rotatingTo: .landscape) } /// Gibt die Ausrichtung wieder frei und dreht zurück ins Hochformat. func releaseLandscape() { - guard UIDevice.current.userInterfaceIdiom != .pad else { return } + guard UIDevice.current.userInterfaceIdiom == .phone else { return } apply(.portrait, rotatingTo: .portrait) } + /// Dreht auf Knopfdruck, ohne die Freiheit danach zu nehmen. + func rotate(to target: UIInterfaceOrientationMask) { + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + apply(.allButUpsideDown, rotatingTo: target) + } + private func apply( _ newMask: UIInterfaceOrientationMask, rotatingTo target: UIInterfaceOrientationMask @@ -50,7 +61,17 @@ final class OrientationLock { // Reihenfolge lehnt das System die Drehung ab, weil die alte Maske // noch gilt — und das sah aus wie „dreht sich nicht richtig". scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() - scene.requestGeometryUpdate(.iOS(interfaceOrientations: target)) + + // **Mit Fehlerbehandler.** Bei eingeschalteter Ausrichtungssperre + // lehnt das System die Drehung ab. Ohne diesen Block wirft UIKit die + // Ablehnung in die Konsole und es sieht aus, als hinge die App — + // dabei ist es eine Einstellung des Nutzers und in Ordnung. Der Film + // läuft dann eben hochkant weiter. + scene.requestGeometryUpdate(.iOS(interfaceOrientations: target)) { fehler in + #if DEBUG + print("Drehung abgelehnt (vermutlich Ausrichtungssperre): \(fehler)") + #endif + } } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 4851954..e09ab48 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -73,6 +73,11 @@ struct RootView: View { /// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb /// wäre er längst null. @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 { Group { @@ -116,9 +121,12 @@ struct RootView: View { // 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 } + .onGeometryChange(for: EdgeInsets.self) { proxy in + proxy.safeAreaInsets + } action: { neue in + screenInsets = neue + statusBarHeight = neue.top + } // Hier statt in der Scene: eine View wertet Beobachtungsänderungen // zuverlässig neu aus, ein Scene-Body nicht. .flixyAccent(theme) @@ -159,6 +167,10 @@ struct RootView: View { // bleiben, die es auf dem neuen Server gar nicht gibt. 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 // Beim Drehen oder Teilen des Fensters wechselt die Form der // Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index e44259b..941a540 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -153,6 +153,34 @@ final class PlayerModel { /// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde /// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an. let surface = PlayerLayerView() + + /// Wie das Bild in die Fläche gelegt wird. + /// + /// **Im Modell und nicht in der Ansicht**, aus demselben Grund wie die + /// Bildebene selbst: der Wechsel zwischen Kachel und Vollbild und jeder + /// Neuaufbau der Engine gehen an der Ansicht vorbei. Läge die Wahl dort, + /// spränge das Bild bei jedem Qualitätswechsel zurück auf „eingepasst". + var videoFit: VideoFit = .fit { + didSet { applyVideoGravity() } + } + + /// Wie viel unten ausgespart bleibt, damit Untertitel sichtbar sind. + /// + /// Im Füllmodus reicht das Bild über die untere Bildschirmkante hinaus — + /// und genau dort zeichnet AVFoundation die Untertitel. Statt sie + /// anzuschneiden, wird die **Bildebene** eingerückt: das kostet rund + /// zwanzig Punkt Bild und behält die Schrift. Im eingepassten Modus ist + /// der Wert null, weil dort ohnehin schwarze Balken liegen. + var subtitleBottomInset: CGFloat = 0 + + /// Wo das Bild aufhört. + public enum VideoFit: Sendable { + /// Ganz sichtbar, mit schwarzen Balken. + case fit + /// Bildfüllend, oben und unten beschnitten. + case fill + } + /// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit. /// /// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil @@ -1668,6 +1696,14 @@ final class PlayerModel { } + /// Legt die gewählte Skalierung auf die Bildebene. + /// + /// Eine eigene Funktion, weil es zwei Anlässe gibt: die Wahl des Nutzers + /// und jeder Engine-Wechsel. Beide müssen dasselbe tun. + private func applyVideoGravity() { + surface.playerLayer.videoGravity = videoFit == .fill ? .resizeAspectFill : .resizeAspect + } + /// Hängt die gehaltene Bildebene an den aktuellen Engine. /// /// Nach **jedem** Engine-Wechsel nötig — also auch nach einem @@ -1677,7 +1713,11 @@ final class PlayerModel { guard let avEngine = engine as? AVPlayerEngine else { return } surface.playerLayer.player = avEngine.player - surface.playerLayer.videoGravity = .resizeAspect + // **Den Zustand lesen, keine Konstante.** Hier stand fest + // `.resizeAspect` — und weil diese Zeile nach jedem Neuaufbau erneut + // läuft, fiel ein auf formatfüllend gestelltes Bild bei jedem + // Qualitätswechsel klammheimlich zurück. + applyVideoGravity() guard AVPictureInPictureController.isPictureInPictureSupported() else { return } if pictureInPicture == nil { diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 5a93b5d..557872d 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -22,6 +22,18 @@ 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 + /// Hochformat nicht. Billiger als die Szene zu befragen, und es + /// aktualisiert sich von selbst. + @Environment(\.verticalSizeClass) private var verticalSizeClass /// Wie hoch die Fensterleiste auf dem Mac ist. /// @@ -56,6 +68,15 @@ struct PlayerView: View { @State private var isFullScreen = false /// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt. private var isMac: Bool { FlixyPlatform.isMac } + /// Ob gedreht und mit zwei Fingern gezoomt werden kann. + /// + /// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der + /// Mac dreht sich gar nicht. Bleibt das Telefon. + private var isPhone: Bool { + !isMac && UIDevice.current.userInterfaceIdiom == .phone + } + /// Ob das Gerät gerade quer liegt. + private var isLandscape: Bool { verticalSizeClass == .compact } /// Wann der Zeiger zuletzt die Bedienelemente geholt hat. @State private var lastPointerReveal: Date = .distantPast @@ -86,6 +107,17 @@ struct PlayerView: View { surface(for: model) .contentShape(.rect) .onTapGesture { primaryTap() } + // **Auseinander füllt, zusammen passt ein.** + // + // Kein stufenloses Zoomen: es gibt genau zwei + // sinnvolle Zustände, und alles dazwischen + // wäre ein Bild, das weder ganz zu sehen ist + // noch den Schirm füllt. Die Geste wählt also + // aus zweien aus, statt einen Faktor zu + // setzen — und der Schwellenwert liegt bewusst + // weit vom Ruhepunkt weg, damit eine wackelige + // Hand nichts umschaltet. + .gesture(zoomGesture(for: model), including: isMac ? .none : .all) } } @@ -175,6 +207,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) // **Unter der Fensterleiste beginnen.** // // Der Player deckt das ganze Fenster ab, und auf dem Mac @@ -306,6 +343,13 @@ struct PlayerView: View { scrubber(for: model) } .padding(FlixySpacing.loose) + // **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) .background { // Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen // Szenen lesbar bleibt, wenig genug, dass das Bild nicht @@ -380,6 +424,42 @@ struct PlayerView: View { .keyboardShortcut("f", modifiers: [.command, .control]) } + // **Einpassen oder füllen — überall ausser auf dem Mac.** + // + // Dort gibt es den Vollbildknopf darüber, und zwei Knöpfe, die + // beide „grösser" bedeuten, nebeneinander sind eine Zumutung. + if !isMac { + Button { + setFit(model.videoFit == .fill ? .fit : .fill, on: model) + } label: { + Image(systemName: model.videoFit == .fill + ? "arrow.down.right.and.arrow.up.left" + : "arrow.up.left.and.arrow.down.right") + .font(.title3) + .padding(FlixySpacing.snug) + } + .accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo")) + } + + // **Drehen — nur am Telefon.** + // + // Ein iPad dreht sich von selbst, sobald man es dreht; ein Telefon + // im Player auch, seit die Sperre weg ist. Der Knopf ist für den + // Fall, dass die Ausrichtungssperre des Systems an ist — dann + // dreht das Gerät nicht mehr von allein, und das ist meistens + // genau dann, wenn man liegt. + if isPhone { + Button { + OrientationLock.shared.rotate(to: isLandscape ? .portrait : .landscape) + revealControls() + } label: { + Image(systemName: isLandscape ? "iphone" : "iphone.landscape") + .font(.title3) + .padding(FlixySpacing.snug) + } + .accessibilityLabel(Text("player.rotate")) + } + // Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die // Tonspur stellt man einmal am Anfang um, die Untertitel mitten im // Film. Ein gemeinsames Sheet bedeutete jedes Mal Scrollen an der @@ -589,9 +669,11 @@ struct PlayerView: View { } } .padding(.horizontal, FlixySpacing.section) + .padding(.leading, screenInsets.leading) + .padding(.trailing, screenInsets.trailing) // Über dem Schieberegler, damit sich beides nicht überdeckt, wenn // die Bedienelemente eingeblendet sind. - .padding(.bottom, showsControls ? 110 : FlixySpacing.section) + .padding(.bottom, (showsControls ? 110 : FlixySpacing.section) + screenInsets.bottom) } .animation(.snappy, value: model.activeMarker?.id) .animation(.snappy, value: model.hasReachedEnd) @@ -739,18 +821,51 @@ struct PlayerView: View { let fläche = VideoSurface( engine: model.engine, surface: model.surface, - isActive: playback.presentation == .full + isActive: playback.presentation == .full, + // **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 ) - if let breite = model.statistics?.width, - let höhe = model.statistics?.height, - breite > 0, höhe > 0 { + // **Im Füllmodus kein Seitenverhältnis.** Die Fläche soll den Schirm + // decken — das Einpassen ist genau das, was hier abgeschaltet wird. + // Dass der schwarze Rand dann keine Klicks mehr abfängt, ist kein + // Verlust: es gibt keinen. + if model.videoFit == .fill { + fläche + } else if let breite = model.statistics?.width, + let höhe = model.statistics?.height, + breite > 0, höhe > 0 { fläche.aspectRatio(CGFloat(breite) / CGFloat(höhe), contentMode: .fit) } else { fläche } } + /// Die Zweifingergeste, die zwischen einpassen und füllen umlegt. + private func zoomGesture(for model: PlayerModel) -> some Gesture { + MagnifyGesture(minimumScaleDelta: 0.1) + .onEnded { wert in + if wert.magnification > 1.1 { + setFit(.fill, on: model) + } else if wert.magnification < 0.9 { + setFit(.fit, on: model) + } + } + } + + /// Legt die Skalierung um und zeigt dabei die Bedienelemente. + /// + /// Das Einblenden gehört dazu: der Knopf wechselt sein Symbol, und wer + /// nach der Geste nicht sieht, welcher Zustand nun gilt, probiert sie + /// ein zweites Mal. + private func setFit(_ neu: PlayerModel.VideoFit, on model: PlayerModel) { + guard model.videoFit != neu else { return } + model.videoFit = neu + revealControls() + } + private func isPlayingPhase(_ model: PlayerModel) -> Bool { if case .playing = model.phase { return true } return false diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift index f97d9d0..97e5098 100644 --- a/Flixy/Views/Player/VideoSurface.swift +++ b/Flixy/Views/Player/VideoSurface.swift @@ -52,18 +52,24 @@ struct VideoSurface: View { /// nicht, wer sie bekommen **soll**. Das steht hier. var isActive = true + /// Wie viel unten frei bleibt, damit Untertitel nicht abgeschnitten werden. + /// + /// Siehe `PlayerModel.subtitleBottomInset`. Hier steht nur die Zahl; was + /// sie bedeutet, entscheidet das Modell. + var bottomInset: CGFloat = 0 + var body: some View { #if canImport(FlixyVLC) if let vlcEngine = engine as? VLCPlayerEngine { - SurfaceContainer(view: vlcEngine.videoView, isActive: isActive) + SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset) } else if engine is AVPlayerEngine { - SurfaceContainer(view: surface, isActive: isActive) + SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset) } else { Color.black } #else if engine is AVPlayerEngine { - SurfaceContainer(view: surface, isActive: isActive) + SurfaceContainer(view: surface, isActive: isActive, bottomInset: bottomInset) } else { Color.black } @@ -130,58 +136,102 @@ final class PlayerLayerView: NSView { private struct SurfaceContainer: UIViewRepresentable { let view: PlatformView let isActive: Bool + let bottomInset: CGFloat - func makeUIView(context: Context) -> PlatformView { - let host = UIView() + func makeUIView(context: Context) -> SurfaceHostView { + let host = SurfaceHostView() host.backgroundColor = .black - if isActive { adopt(view, into: host) } + host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) + if isActive { host.adopt(view) } return host } - func updateUIView(_ host: PlatformView, context: Context) { + func updateUIView(_ host: SurfaceHostView, context: Context) { + host.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) // Wer nicht dran ist, greift auch nicht zu. Ohne diese Zeile holen // sich beide Ansichten dieselbe Fläche, und die Reihenfolge // entscheidet — mal richtig, mal schwarz. guard isActive, view.superview !== host else { return } - adopt(view, into: host) + host.adopt(view) } } #else private struct SurfaceContainer: NSViewRepresentable { let view: PlatformView let isActive: Bool + let bottomInset: CGFloat - func makeNSView(context: Context) -> PlatformView { - let host = NSView() + func makeNSView(context: Context) -> SurfaceHostView { + let host = SurfaceHostView() host.wantsLayer = true host.layer?.backgroundColor = NSColor.black.cgColor - if isActive { adopt(view, into: host) } + host.bottomInset = bottomInset + if isActive { host.adopt(view) } return host } - func updateNSView(_ host: PlatformView, context: Context) { + func updateNSView(_ host: SurfaceHostView, context: Context) { + host.bottomInset = bottomInset guard isActive, view.superview !== host else { return } - adopt(view, into: host) + host.adopt(view) } } #endif -/// Hängt die Fläche in ihre Hülle. +/// Die Hülle, die der Bildfläche ihren Platz zuweist. /// -/// Sie wird **nicht** unmittelbar an SwiftUI zurückgegeben, sondern in eine -/// eigene Hülle gehängt. Der Unterschied zählt genau in dem Moment, in dem es -/// Kachel und Vollbild kurz gleichzeitig gibt: eine Ansicht hat nur einen -/// Vorgesetzten, und wer sie zuletzt bekommt, gewinnt. Gab SwiftUI sie -/// unmittelbar heraus, nahm das Abräumen der einen der anderen das Bild weg. -private func adopt(_ view: PlatformView, into host: PlatformView) { - #if canImport(UIKit) - view.translatesAutoresizingMaskIntoConstraints = true - view.frame = host.bounds - view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - #else - view.translatesAutoresizingMaskIntoConstraints = true - view.frame = host.bounds - view.autoresizingMask = [.width, .height] - #endif - host.addSubview(view) +/// **Warum nicht mehr `autoresizingMask`.** Bisher bekam die Fläche die +/// Grösse der Hülle und eine Maske, die sie mitwachsen liess. Eine Maske hält +/// Ränder aber **verhältnismässig**: ein Einzug von zwanzig Punkten wüchse +/// beim Drehen mit und wäre im Querformat plötzlich vierzig. Ein fester Rand +/// verlangt echtes Layout — und das sind genau diese vier Zeilen. +#if canImport(UIKit) +final class SurfaceHostView: UIView { + /// Was ringsum frei bleibt. Heute nur unten, siehe `VideoSurface`. + var contentInset: UIEdgeInsets = .zero { + didSet { + guard contentInset != oldValue else { return } + setNeedsLayout() + } + } + + /// Hängt die Fläche ein — ohne Maske, die Grösse kommt aus `layoutSubviews`. + func adopt(_ view: UIView) { + view.translatesAutoresizingMaskIntoConstraints = true + view.autoresizingMask = [] + addSubview(view) + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + subviews.first?.frame = bounds.inset(by: contentInset) + } } +#else +final class SurfaceHostView: NSView { + var bottomInset: CGFloat = 0 { + didSet { + guard bottomInset != oldValue else { return } + needsLayout = true + } + } + + func adopt(_ view: NSView) { + view.translatesAutoresizingMaskIntoConstraints = true + view.autoresizingMask = [] + addSubview(view) + needsLayout = true + } + + override func layout() { + super.layout() + // Auf dem Mac liegt der Ursprung unten links: einzurücken heisst + // hier, den Ursprung anzuheben und die Höhe zu kürzen. + var rahmen = bounds + rahmen.origin.y += bottomInset + rahmen.size.height -= bottomInset + subviews.first?.frame = rahmen + } +} +#endif diff --git a/Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift b/Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift new file mode 100644 index 0000000..4c14f17 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/ScreenInsets.swift @@ -0,0 +1,28 @@ +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) + } +}