mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Support the iPhone Duo interior display in the player
The interior display refuses programmatic rotation (UISceneErrorDomain 101: "The current windowing mode does not allow for programmatic changes to interface orientation"), so the rotate/fullscreen button did nothing there and the failure was swallowed. - OrientationManager tracks `canRotateProgrammatically` (from the hinge state, cleared when a geometry request is refused) and is @Observable. - The fullscreen button takes the iPad path when rotation is unavailable: details toggle in portrait, floating panel toggle in landscape, icon follows the panel state; orientation lock is hidden. The refused request itself falls back to the details toggle. Accelerometer-driven rotation requests are gated the same way. - Corner layout gate is size-only on iOS (≥900×600 including safe areas, so the status bar toggling with the layout can't oscillate it); the layout-mode setting is exposed on foldables via a hinge presence check. - Corner layout on iPhone always hides the status bar (the interior's landscape bar is a ~57pt column) and pads by the live top inset only, instead of the transient 82pt inset seen while unfolding. - The player hosting controller nudges its safe area after size transitions so fold/unfold doesn't leave SwiftUI with stale insets.
This commit is contained in:
@@ -8,16 +8,41 @@
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import Observation
|
||||
import UIKit
|
||||
|
||||
/// Provides supported interface orientations.
|
||||
/// Supports in-app orientation locking to the current orientation.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class OrientationManager {
|
||||
static let shared = OrientationManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Whether `requestGeometryUpdate(.iOS(interfaceOrientations:))` is honored
|
||||
/// in the current windowing mode. False on the iPhone Duo interior display,
|
||||
/// where the system refuses programmatic rotation (`UISceneErrorDomain` 101)
|
||||
/// the same way it does for iPad multitasking. Set from the hinge state and
|
||||
/// cleared when a request is refused, so the player falls back to the
|
||||
/// iPad-style details toggle instead of doing nothing.
|
||||
var canRotateProgrammatically = true
|
||||
|
||||
/// `UISceneErrorDomain` code returned when the windowing mode forbids
|
||||
/// programmatic interface orientation changes.
|
||||
static let orientationChangeForbiddenErrorCode = 101
|
||||
|
||||
/// Records a refused rotation request; returns true when the error is the
|
||||
/// windowing-mode refusal (as opposed to a transient failure).
|
||||
@discardableResult
|
||||
func noteGeometryUpdateFailure(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
guard nsError.domain == UISceneErrorDomain,
|
||||
nsError.code == Self.orientationChangeForbiddenErrorCode else { return false }
|
||||
canRotateProgrammatically = false
|
||||
return true
|
||||
}
|
||||
|
||||
/// The orientation to lock to when `isLocked` is true.
|
||||
/// Updated when orientation lock is enabled to capture current orientation.
|
||||
private var lockedOrientation: UIInterfaceOrientationMask?
|
||||
|
||||
@@ -134,8 +134,8 @@ struct ControlsSectionRenderer: View {
|
||||
}
|
||||
|
||||
case .orientationLock:
|
||||
// Only on iPhone (not iPad)
|
||||
if !actions.isIPad, actions.onToggleOrientationLock != nil {
|
||||
// Only where rotation is under the app's control (not iPad, not Duo interior)
|
||||
if !actions.usesRotationFreeFullscreen, actions.onToggleOrientationLock != nil {
|
||||
controlButton(
|
||||
systemImage: actions.orientationLockIcon,
|
||||
tint: actions.isOrientationLocked ? .red : .white
|
||||
|
||||
@@ -192,11 +192,17 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
GeometryReader { outer in
|
||||
let liveTopInset = outer.safeAreaInsets.top
|
||||
#if os(iOS)
|
||||
let knownTopInset = statusBarController?.knownStatusBarHeight ?? 0
|
||||
// iPhone (Duo interior) never shows the status bar in this
|
||||
// layout, so there is no strip to hold: pad by the live inset
|
||||
// only. Remembering a past inset here would keep the 82pt top
|
||||
// inset seen transiently while unfolding.
|
||||
let holdsStatusBarStrip = UIDevice.current.userInterfaceIdiom == .pad
|
||||
let knownTopInset = holdsStatusBarStrip ? (statusBarController?.knownStatusBarHeight ?? 0) : 0
|
||||
#else
|
||||
let holdsStatusBarStrip = true
|
||||
let knownTopInset: CGFloat = 0
|
||||
#endif
|
||||
let topInset = liveTopInset > 0 ? liveTopInset : max(stableTopInset, knownTopInset)
|
||||
let topInset = liveTopInset > 0 || !holdsStatusBarStrip ? liveTopInset : max(stableTopInset, knownTopInset)
|
||||
|
||||
layout(topInset: topInset)
|
||||
.padding(.top, topInset)
|
||||
|
||||
@@ -21,6 +21,13 @@ extension ExpandedPlayerSheet {
|
||||
/// narrow windows).
|
||||
static let minWidthForCornerLayout: CGFloat = 900
|
||||
|
||||
/// Minimum height (safe areas included) for the corner layout on iOS:
|
||||
/// keeps it off tall-phone landscape (iPhone Pro Max is 956×440) while
|
||||
/// the iPhone Duo interior (951×669) and every iPad qualify. The gate is
|
||||
/// size-only — the Duo interior behaves like a small iPad, and the
|
||||
/// exterior display never reaches the width.
|
||||
static let minHeightForCornerLayout: CGFloat = 600
|
||||
|
||||
/// Tabletop (foldable, hinge partially open): vertical room reserved on
|
||||
/// the lower half for the top bar row and for the bottom bar, keeping the
|
||||
/// center controls and side sliders clear of both.
|
||||
@@ -43,13 +50,15 @@ extension ExpandedPlayerSheet {
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Whether the corner layout should be used for the given size: iPad/macOS
|
||||
/// only, opted into via the preset's panel settings, wide enough, and in a
|
||||
/// widescreen orientation. Evaluated per layout pass so window resizes and
|
||||
/// Split View changes fall back automatically.
|
||||
/// Whether the corner layout should be used for the given size: opted into
|
||||
/// via the preset's panel settings, large enough (iPad, macOS, iPhone Duo
|
||||
/// interior), and in a widescreen orientation. Evaluated per layout pass
|
||||
/// so window resizes and Split View changes fall back automatically.
|
||||
/// `size` must include the safe areas so the status bar toggling with the
|
||||
/// layout can't feed back into the decision.
|
||||
func isCornerLayout(size: CGSize) -> Bool {
|
||||
#if os(iOS)
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else { return false }
|
||||
guard size.height >= Self.minHeightForCornerLayout else { return false }
|
||||
#endif
|
||||
return playerControlsLayout.effectivePanelSettings.effectiveLayoutMode == .corner
|
||||
&& size.width >= Self.minWidthForCornerLayout
|
||||
|
||||
@@ -38,6 +38,12 @@ extension ExpandedPlayerSheet {
|
||||
/// When orientation lock is enabled, locks to the target orientation before rotating
|
||||
/// (keeps orientation restricted the entire time, just changes which orientation is allowed).
|
||||
func toggleFullscreen() {
|
||||
// Windowing modes that refuse programmatic rotation (iPhone Duo
|
||||
// interior) get the iPad behavior: toggle the details panel instead.
|
||||
guard OrientationManager.shared.canRotateProgrammatically else {
|
||||
togglePortraitDetailsPanel()
|
||||
return
|
||||
}
|
||||
guard let windowScene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
||||
@@ -51,6 +57,7 @@ extension ExpandedPlayerSheet {
|
||||
fromSize: screenBounds.size,
|
||||
toSize: isCurrentlyLandscape ? CGSize(width: screenBounds.height, height: screenBounds.width) : nil)
|
||||
|
||||
|
||||
if isCurrentlyLandscape {
|
||||
// Exit fullscreen → rotate to portrait
|
||||
MPVLogging.log("toggleFullscreen: exiting to portrait")
|
||||
@@ -58,7 +65,9 @@ extension ExpandedPlayerSheet {
|
||||
if isOrientationLocked {
|
||||
orientationManager.lock(to: .portrait)
|
||||
}
|
||||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { _ in }
|
||||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { [self] error in
|
||||
handleFullscreenRotationFailure(error)
|
||||
}
|
||||
} else {
|
||||
// Enter fullscreen → rotate to landscape
|
||||
let targetOrientation = Self.currentLandscapeInterfaceOrientation()
|
||||
@@ -68,7 +77,9 @@ extension ExpandedPlayerSheet {
|
||||
if isOrientationLocked {
|
||||
orientationManager.lock(to: .landscape)
|
||||
}
|
||||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { _ in }
|
||||
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { [self] error in
|
||||
handleFullscreenRotationFailure(error)
|
||||
}
|
||||
// After rotation completes, re-lock to the specific landscape orientation
|
||||
// Use a delay since completion handler isn't reliable for waiting for rotation
|
||||
if isOrientationLocked {
|
||||
@@ -80,6 +91,36 @@ extension ExpandedPlayerSheet {
|
||||
}
|
||||
}
|
||||
|
||||
/// A refused `requestGeometryUpdate` means this windowing mode never
|
||||
/// rotates programmatically; remember that and do what the tap was meant
|
||||
/// to do — fill the screen with the video — via the details panel.
|
||||
private func handleFullscreenRotationFailure(_ error: Error) {
|
||||
MPVLogging.log("toggleFullscreen: requestGeometryUpdate failed: \(error.localizedDescription)")
|
||||
guard OrientationManager.shared.noteGeometryUpdateFailure(error) else { return }
|
||||
if appEnvironment?.settingsManager.inAppOrientationLock ?? false {
|
||||
OrientationManager.shared.unlock()
|
||||
}
|
||||
togglePortraitDetailsPanel()
|
||||
}
|
||||
|
||||
/// Fullscreen fallback when rotation isn't available: show/hide the
|
||||
/// portrait details panel, mirroring `onToggleDetailsVisibility`.
|
||||
private func togglePortraitDetailsPanel() {
|
||||
hideControlsDuringTransition = true
|
||||
if !isPortraitPanelVisible {
|
||||
navigationCoordinator?.pinchPanscan = 0
|
||||
}
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isPortraitPanelVisible.toggle()
|
||||
}
|
||||
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
||||
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = isPortraitPanelVisible
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
hideControlsDuringTransition = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rotation Monitoring
|
||||
|
||||
/// Simplified rotation monitoring - handles layout transitions via accelerometer.
|
||||
@@ -93,7 +134,8 @@ extension ExpandedPlayerSheet {
|
||||
rotationManager.onLandscapeDetected = { [weak appEnvironment] in
|
||||
Task { @MainActor in
|
||||
guard let appEnvironment else { return }
|
||||
guard !appEnvironment.settingsManager.inAppOrientationLock else { return }
|
||||
guard !appEnvironment.settingsManager.inAppOrientationLock,
|
||||
OrientationManager.shared.canRotateProgrammatically else { return }
|
||||
|
||||
let playerState = appEnvironment.playerService.state
|
||||
|
||||
@@ -120,7 +162,8 @@ extension ExpandedPlayerSheet {
|
||||
rotationManager.onPortraitDetected = { [weak appEnvironment] in
|
||||
Task { @MainActor in
|
||||
guard let appEnvironment else { return }
|
||||
guard !appEnvironment.settingsManager.inAppOrientationLock else { return }
|
||||
guard !appEnvironment.settingsManager.inAppOrientationLock,
|
||||
OrientationManager.shared.canRotateProgrammatically else { return }
|
||||
|
||||
// Request portrait rotation
|
||||
guard let windowScene = UIApplication.shared.connectedScenes
|
||||
@@ -140,7 +183,8 @@ extension ExpandedPlayerSheet {
|
||||
rotationManager.onLandscapeOrientationChanged = { [weak appEnvironment] newOrientation in
|
||||
Task { @MainActor in
|
||||
guard let appEnvironment else { return }
|
||||
guard !appEnvironment.settingsManager.inAppOrientationLock else { return }
|
||||
guard !appEnvironment.settingsManager.inAppOrientationLock,
|
||||
OrientationManager.shared.canRotateProgrammatically else { return }
|
||||
|
||||
guard let windowScene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
|
||||
@@ -307,6 +307,18 @@ struct ExpandedPlayerSheet: View {
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Whether the status bar and home indicator are hidden for the current
|
||||
/// layout state (see the modifier site for the rationale).
|
||||
private var hidesSystemOverlays: Bool {
|
||||
if navigationCoordinator?.isCornerLayoutActive == true {
|
||||
return UIDevice.current.userInterfaceIdiom == .phone
|
||||
|| !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
}
|
||||
return isInWideScreenLayout || !isPortraitPanelVisible
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Player Content
|
||||
|
||||
@ViewBuilder
|
||||
@@ -314,7 +326,13 @@ struct ExpandedPlayerSheet: View {
|
||||
GeometryReader { geometry in
|
||||
let wideScreen = isWideScreenLayout(size: geometry.size)
|
||||
#if os(iOS) || os(macOS)
|
||||
let cornerActive = isCornerLayout(size: geometry.size)
|
||||
// Gate on the full size including safe areas: the corner layout
|
||||
// shows the status bar, which would otherwise shrink the measured
|
||||
// height and flip the decision back (layout oscillation).
|
||||
let cornerActive = isCornerLayout(size: CGSize(
|
||||
width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing,
|
||||
height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom
|
||||
))
|
||||
#endif
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
@@ -480,18 +498,13 @@ struct ExpandedPlayerSheet: View {
|
||||
#if os(iOS)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
// Corner layout with panels visible keeps the system status bar and
|
||||
// home indicator; hiding the panels (fullscreen state) hides them so
|
||||
// the video and controls reach the screen edges.
|
||||
.playerStatusBarHidden(
|
||||
navigationCoordinator?.isCornerLayoutActive == true
|
||||
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
: (isInWideScreenLayout || !isPortraitPanelVisible)
|
||||
)
|
||||
.persistentSystemOverlays(
|
||||
(navigationCoordinator?.isCornerLayoutActive == true
|
||||
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
: (isInWideScreenLayout || !isPortraitPanelVisible)) ? .hidden : .automatic
|
||||
)
|
||||
// home indicator on iPad (a thin strip); hiding the panels (fullscreen
|
||||
// state) hides them so the video and controls reach the screen edges.
|
||||
// On the iPhone Duo interior the landscape status bar is a ~57pt
|
||||
// column plus top row that would inset the whole layout, so the
|
||||
// corner layout always hides it there.
|
||||
.playerStatusBarHidden(hidesSystemOverlays)
|
||||
.persistentSystemOverlays(hidesSystemOverlays ? .hidden : .automatic)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,22 @@ private final class ExpandedPlayerHostingController<Content: View>: UIHostingCon
|
||||
guard let window = self?.view.window,
|
||||
let scene = window.windowScene else { return }
|
||||
window.frame = scene.coordinateSpace.bounds
|
||||
}, completion: { [weak self] _ in
|
||||
// Folding/unfolding the iPhone Duo (and display handoffs in
|
||||
// general) changes the safe area without a rotation; SwiftUI
|
||||
// keeps serving the previous insets to the sheet's GeometryReader
|
||||
// until the hosting controller's safe area is invalidated. Nudge
|
||||
// additionalSafeAreaInsets, the same trick the dismiss path uses
|
||||
// for the main window.
|
||||
guard let self else { return }
|
||||
let originalInsets = self.additionalSafeAreaInsets
|
||||
self.additionalSafeAreaInsets = UIEdgeInsets(
|
||||
top: originalInsets.top + 1,
|
||||
left: originalInsets.left,
|
||||
bottom: originalInsets.bottom,
|
||||
right: originalInsets.right
|
||||
)
|
||||
self.additionalSafeAreaInsets = originalInsets
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ struct HingeLayoutObserver: ViewModifier {
|
||||
content.onHingeChange { _, context in
|
||||
// `hinge` is nil on devices without a hinge and when the view
|
||||
// leaves a hierarchy that provides hinge updates.
|
||||
if let status = context.hinge?.status {
|
||||
// Interior display (hinge open): the system refuses
|
||||
// programmatic rotation, so the player must not offer it.
|
||||
OrientationManager.shared.canRotateProgrammatically = status == .closed
|
||||
}
|
||||
let tabletop = context.hinge?.status == .partiallyOpen
|
||||
guard tabletop != isTabletop else { return }
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
@@ -37,12 +42,41 @@ struct HingeLayoutObserver: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether the device has a hinge at all (foldable iPhone), for UI
|
||||
/// that only applies to the interior display, such as the corner layout option.
|
||||
private struct HingePresenceObserver: ViewModifier {
|
||||
@Binding var hasHinge: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 27.1, *) {
|
||||
content.onHingeChange { _, context in
|
||||
// nil also when leaving a hinge-providing hierarchy; keep the last value then
|
||||
guard context.hinge != nil else { return }
|
||||
hasHinge = true
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Tracks the device hinge and writes whether the tabletop player layout
|
||||
/// should be active. No-op before iOS 27.1 and on devices without a hinge.
|
||||
func observeHingeLayout(isTabletop: Binding<Bool>) -> some View {
|
||||
modifier(HingeLayoutObserver(isTabletop: isTabletop))
|
||||
}
|
||||
|
||||
/// Writes whether the device has a hinge. No-op before iOS 27.1.
|
||||
func observeHinge(hasHinge: Binding<Bool>) -> some View {
|
||||
modifier(HingePresenceObserver(hasHinge: hasHinge))
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
extension View {
|
||||
func observeHinge(hasHinge: Binding<Bool>) -> some View { self }
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -51,6 +51,20 @@ struct PlayerControlsActions {
|
||||
#if os(iOS)
|
||||
/// Whether running on iPad
|
||||
let isIPad: Bool
|
||||
|
||||
/// Whether the system honors programmatic rotation requests. False on the
|
||||
/// iPhone Duo interior display, which then gets the iPad fullscreen
|
||||
/// behavior (details toggle, no rotate glyph, no orientation lock).
|
||||
var canRotate: Bool = true
|
||||
|
||||
/// iPad, or an iPhone windowing mode that can't rotate on request.
|
||||
var usesRotationFreeFullscreen: Bool { isIPad || !canRotate }
|
||||
|
||||
/// Rotation-free fullscreen in a layout without a details toggle: the
|
||||
/// button shows/hides the panel instead, so the icon tracks the panel.
|
||||
var togglesPanelOnFullscreen: Bool {
|
||||
usesRotationFreeFullscreen && onToggleDetailsVisibility == nil && onTogglePanel != nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Whether volume controls should show (mpv mode)
|
||||
@@ -271,8 +285,9 @@ struct PlayerControlsActions {
|
||||
/// Uses rotation icons when tapping will cause device rotation,
|
||||
/// otherwise uses standard fullscreen arrows.
|
||||
var fullscreenIcon: String {
|
||||
if isCornerLayout {
|
||||
// Corner layout: reflects the panels state, not orientation
|
||||
if isCornerLayout || togglesPanelOnFullscreen {
|
||||
// Corner layout / rotation-free widescreen: reflects the panel
|
||||
// state, not orientation
|
||||
return isPanelVisible ? "arrow.up.left.and.arrow.down.right" : "arrow.down.right.and.arrow.up.left"
|
||||
}
|
||||
if willRotateOnFullscreenToggle {
|
||||
@@ -302,9 +317,16 @@ struct PlayerControlsActions {
|
||||
|
||||
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
|
||||
|
||||
if isIPad {
|
||||
// iPad: always toggle details visibility
|
||||
onToggleDetailsVisibility?()
|
||||
if usesRotationFreeFullscreen {
|
||||
// iPad (or no programmatic rotation): toggle details visibility.
|
||||
// The widescreen layout has no details toggle — there fullscreen
|
||||
// means hiding the floating panel, like the corner layout.
|
||||
if let onToggleDetailsVisibility {
|
||||
onToggleDetailsVisibility()
|
||||
} else if togglesPanelOnFullscreen {
|
||||
onHideControls?()
|
||||
onTogglePanel?()
|
||||
}
|
||||
} else if isActualWidescreenLayout && isFullscreen && !isWidescreenVideo {
|
||||
// iPhone in landscape with portrait video fullscreen: rotate back to portrait
|
||||
onToggleFullscreen?()
|
||||
@@ -320,8 +342,9 @@ struct PlayerControlsActions {
|
||||
/// Whether tapping fullscreen will cause device rotation.
|
||||
/// Mirrors the logic in `performFullscreenTap()`
|
||||
var willRotateOnFullscreenToggle: Bool {
|
||||
// iPad never rotates via fullscreen button
|
||||
guard !isIPad else { return false }
|
||||
// iPad never rotates via fullscreen button; neither does a windowing
|
||||
// mode that refuses programmatic rotation
|
||||
guard !usesRotationFreeFullscreen else { return false }
|
||||
|
||||
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
|
||||
|
||||
@@ -359,7 +382,9 @@ struct PlayerControlsActions {
|
||||
return onTogglePanel != nil
|
||||
}
|
||||
|
||||
// iPad widescreen (true landscape): hidden (can't force rotation)
|
||||
// iPad widescreen (true landscape): hidden (can't force rotation).
|
||||
// Only real iPads: a rotation-free iPhone (Duo interior) with the
|
||||
// panel hidden still needs the button to bring the panel back.
|
||||
let isActualWidescreen = isWideScreenLayout && onTogglePanel != nil
|
||||
let showFullscreenButton = !(isIPad && isActualWidescreen)
|
||||
let hasFullscreenAction = onToggleFullscreen != nil || onToggleDetailsVisibility != nil
|
||||
|
||||
@@ -422,6 +422,7 @@ struct PlayerControlsView: View {
|
||||
isCornerLayout: isCornerLayout,
|
||||
panelSide: panelSide,
|
||||
isIPad: isIPad,
|
||||
canRotate: OrientationManager.shared.canRotateProgrammatically,
|
||||
showVolumeControls: showVolumeControls,
|
||||
showDebugButton: onToggleDebug != nil,
|
||||
showCloseButton: onClose != nil,
|
||||
|
||||
@@ -22,7 +22,8 @@ struct PanelSettingsView: View {
|
||||
@State private var commentsShowAvatars: Bool = true
|
||||
@State private var commentsPillMode: CommentsPillMode = .pill
|
||||
|
||||
/// Corner layout exists on iPad and macOS only.
|
||||
/// Corner layout exists on iPad and macOS, and on a foldable iPhone whose
|
||||
/// interior display is large enough (see `hasHinge`).
|
||||
static var supportsCornerLayout: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
@@ -31,12 +32,17 @@ struct PanelSettingsView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Foldable iPhone: the interior display qualifies for the corner layout.
|
||||
@State private var hasHinge = false
|
||||
|
||||
private var showsLayoutSection: Bool { Self.supportsCornerLayout || hasHinge }
|
||||
|
||||
/// iPhone has no page bar: the list configures inline sections instead.
|
||||
private var usesSections: Bool { PanelSettings.isSectionsOnlyDevice }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if Self.supportsCornerLayout {
|
||||
if showsLayoutSection {
|
||||
layoutSection
|
||||
}
|
||||
visibilitySection
|
||||
@@ -66,6 +72,7 @@ struct PanelSettingsView: View {
|
||||
.onChange(of: viewModel.activePreset?.id) { _, _ in
|
||||
syncLocalState()
|
||||
}
|
||||
.observeHinge(hasHinge: $hasHinge)
|
||||
}
|
||||
|
||||
// MARK: - Layout Section
|
||||
|
||||
Reference in New Issue
Block a user