From 99d3d3103160dafd38a4982b337232c386269861 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 30 Sep 2026 22:02:12 +0200 Subject: [PATCH] Add iPhone Duo tabletop player layout and exterior display insets Observe the foldable hinge with `onHingeChange` (iOS 27.1): while it is partially open and the portrait panel is hidden, the video is fitted to the upper half of the screen and every control (title bar, center controls, side sliders, bottom bar) moves to the lower half. Pinch to fill is disabled in that state. `YATTEE_FORCE_TABLETOP=1` in the launch environment forces the layout on simulators without a hinge control. The exterior display has a vertical status bar with a trailing safe area inset, which squeezed the fullscreen portrait player into the content width. The video and controls layers now span the full window width (shifted under the leading inset, like the existing vertical handling) and the controls pad their edges by the sheet's actual asymmetric horizontal insets instead of the symmetric max. Requires the iOS 27.1 SDK (Xcode 27.1). --- .../Player/ExpandedPlayerSheet+Layouts.swift | 71 +++++++++++++++---- Yattee/Views/Player/ExpandedPlayerSheet.swift | 7 ++ Yattee/Views/Player/HingeLayoutObserver.swift | 48 +++++++++++++ Yattee/Views/Player/PlayerControlsView.swift | 16 ++++- 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 Yattee/Views/Player/HingeLayoutObserver.swift diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index bbeed1a1..6c480747 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -21,6 +21,12 @@ extension ExpandedPlayerSheet { /// narrow windows). static let minWidthForCornerLayout: CGFloat = 900 + /// 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. + static let tabletopTopBarInset: CGFloat = 72 + static let tabletopBottomBarInset: CGFloat = 96 + // MARK: - Layout Detection /// Check if widescreen layout should be used based on available size. @@ -150,9 +156,16 @@ extension ExpandedPlayerSheet { // Full screen size including safe areas (for portrait fullscreen) let safeAreaInsets = geometry.safeAreaInsets let fullScreenHeight = max(1, contentHeight + safeAreaInsets.top + safeAreaInsets.bottom) + // Displays with a vertical status bar (iPhone Duo exterior) inset the + // content horizontally too. With the panel hidden the video and the + // controls layer span the full width and shift under the leading + // inset, mirroring `fullscreenPlayerOffset` vertically; the controls + // pad their edges by the window's horizontal safe area themselves. + let fullScreenWidth = max(1, contentWidth + safeAreaInsets.leading + safeAreaInsets.trailing) // Use content size for layout, full screen size for portrait fullscreen controls let screenHeight = contentHeight - let screenWidth = contentWidth + let screenWidth = isPortraitPanelVisible ? contentWidth : fullScreenWidth + let fullscreenPlayerXOffset: CGFloat = isPortraitPanelVisible ? 0 : -safeAreaInsets.leading // Reserve minimum space for panel when pinned // Dynamic height based on description content @@ -180,27 +193,41 @@ extension ExpandedPlayerSheet { let maxVideoHeight = max(1, screenHeight - minPanelHeight) + // Tabletop (foldable hinge partially open, panel hidden): the upper + // half of the screen is the video area, the lower half holds every + // control. `layoutHeight` replaces the full screen height wherever the + // fullscreen video is fitted and positioned. + #if os(iOS) + let isTabletop = isTabletopHinge && !isPortraitPanelVisible + #else + let isTabletop = false + #endif + let layoutHeight = isTabletop ? fullScreenHeight / 2 : fullScreenHeight + // Calculate fit size (aspect-fit within screen bounds, but capped to show panel) let fitHeightFromWidth = screenWidth / aspectRatio let fitWidthFromHeight = screenHeight * aspectRatio let uncappedFitHeight: CGFloat = fitHeightFromWidth > screenHeight ? screenHeight : fitHeightFromWidth let uncappedFitWidth: CGFloat = fitHeightFromWidth > screenHeight ? fitWidthFromHeight : screenWidth - // Apply max height constraint to leave space for panel - let fitHeight = max(1, min(uncappedFitHeight, maxVideoHeight)) + // Apply max height constraint to leave space for panel (or to the + // upper half in tabletop) + let fitHeightCap = isTabletop ? min(maxVideoHeight, layoutHeight) : maxVideoHeight + let fitHeight = max(1, min(uncappedFitHeight, fitHeightCap)) let fitWidth = max(1, fitHeight < uncappedFitHeight ? fitHeight * aspectRatio : uncappedFitWidth) // Calculate fullscreen fit size (aspect-fit within full screen including safe areas) let fullscreenFitHeightFromWidth = screenWidth / aspectRatio - let fullscreenFitHeight: CGFloat = max(1, fullscreenFitHeightFromWidth > fullScreenHeight ? fullScreenHeight : fullscreenFitHeightFromWidth) + let fullscreenFitHeight: CGFloat = max(1, fullscreenFitHeightFromWidth > layoutHeight ? layoutHeight : fullscreenFitHeightFromWidth) // Calculate fill size (aspect-fill to cover screen) let fillHeight: CGFloat = max(1, fitHeightFromWidth >= screenHeight ? fitHeightFromWidth : screenHeight) let fillWidth: CGFloat = max(1, fitHeightFromWidth >= screenHeight ? screenWidth : fitWidthFromHeight) #if os(iOS) - // Use UIKit pinch gesture panscan from NavigationCoordinator - let panscan = !isPortraitPanelVisible ? (navigationCoordinator?.pinchPanscan ?? 0.0) : 0.0 + // Use UIKit pinch gesture panscan from NavigationCoordinator. + // Tabletop keeps the video fitted to the upper half, no fill. + let panscan = !isPortraitPanelVisible && !isTabletop ? (navigationCoordinator?.pinchPanscan ?? 0.0) : 0.0 #else let panscan = 0.0 #endif @@ -216,7 +243,7 @@ extension ExpandedPlayerSheet { // Calculate player height - animates between fit and fullscreen // Use full screen height when panel is hidden for immersive controls - let playerHeight = !isPortraitPanelVisible ? fullScreenHeight : fitHeight + let playerHeight = !isPortraitPanelVisible ? layoutHeight : fitHeight // Panel height when pinned (capped by maxPanelHeight for widescreen videos without description) let naturalPanelHeight = screenHeight - fitHeight @@ -243,7 +270,7 @@ extension ExpandedPlayerSheet { // top and bottom insets differ (the player window adds extra bottom // inset), so animating to screenHeight/2 and then flipping to the hidden // base position causes a visible jump of (bottom - top) / 2 points. - let hiddenVideoY = fullScreenHeight / 2 - safeAreaInsets.top + let hiddenVideoY = layoutHeight / 2 - safeAreaInsets.top let videoY: CGFloat = { if isPortraitPanelVisible { @@ -268,7 +295,7 @@ extension ExpandedPlayerSheet { videoWidth: videoWidth, videoHeight: videoHeight, screenWidth: screenWidth, - screenHeight: fullScreenHeight, + screenHeight: layoutHeight, isFullscreen: !isPortraitPanelVisible, showControls: false, activeLayout: playerControlsLayout @@ -309,12 +336,18 @@ extension ExpandedPlayerSheet { // When panel visible: video fills the controls area (0 to fitHeight) // When fullscreen: video centered on full screen // During drag: expand videoAreaHeight so bottom bar moves down with panel - videoAreaTop: isPortraitPanelVisible ? 0 : (fullScreenHeight - fullscreenFitHeight) / 2, - videoAreaHeight: isPortraitPanelVisible ? draggedControlsHeight : fullscreenFitHeight, + // Tabletop: the controls' "video area" is the lower half so the + // center controls, sliders and top bar all land below the hinge. + // The center zone excludes the top bar row and the bottom bar so + // the side sliders don't collide with either. + videoAreaTop: isTabletop ? layoutHeight + Self.tabletopTopBarInset : (isPortraitPanelVisible ? 0 : (fullScreenHeight - fullscreenFitHeight) / 2), + videoAreaHeight: isTabletop ? layoutHeight - Self.tabletopTopBarInset - Self.tabletopBottomBarInset : (isPortraitPanelVisible ? draggedControlsHeight : fullscreenFitHeight), // Pass stable fitHeight for slider sizing (doesn't change during drag) // Panel visible or dragging: use fitHeight for stable sizing // Fullscreen (after drag completes): pass nil so sliders use geometry.size.height - videoFitHeight: (isPortraitPanelVisible || isPanelDragging) ? fitHeight : nil, + videoFitHeight: isTabletop ? layoutHeight : ((isPortraitPanelVisible || isPanelDragging) ? fitHeight : nil), + isTabletop: isTabletop, + horizontalSafeArea: (safeAreaInsets.leading, safeAreaInsets.trailing), activeLayout: playerControlsLayout ) .frame(width: screenWidth, height: draggedControlsHeight) @@ -326,7 +359,7 @@ extension ExpandedPlayerSheet { // Player positioned based on panel state playerView - .position(x: screenWidth / 2, y: videoY) + .position(x: screenWidth / 2 + fullscreenPlayerXOffset, y: videoY) // Animate video position when not dragging (matches panel animation behavior) .animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoYOffset) .animation(panelRevealOffset != 0 ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: isPortraitPanelVisible) @@ -344,7 +377,7 @@ extension ExpandedPlayerSheet { // Position exactly like playerView - at videoY with same offset // This ensures controls y=0 aligns with screen y=0 controlsLayer - .position(x: screenWidth / 2, y: fullScreenHeight / 2 + fullscreenPlayerOffset) + .position(x: screenWidth / 2 + fullscreenPlayerXOffset, y: fullScreenHeight / 2 + fullscreenPlayerOffset) } } // Reveal gesture - swipe up to show panel when hidden (simultaneous so taps still work for controls) @@ -1076,11 +1109,17 @@ extension ExpandedPlayerSheet { videoAreaTop: CGFloat = 0, videoAreaHeight: CGFloat? = nil, videoFitHeight: CGFloat? = nil, + isTabletop: Bool = false, + horizontalSafeArea: (leading: CGFloat, trailing: CGFloat) = (0, 0), activeLayout: PlayerControlsLayout = .default ) -> some View { // Controls always use full screen dimensions to prevent layout shifts let controlsWidth = max(1, screenWidth) let controlsHeight = max(1, screenHeight) + // Tabletop: controls y=0 is the screen top, so the top bar moves just + // below the hinge (the center zone starts one top-bar inset lower), + // and the pinch-to-fill is off + let topBarOffset: CGFloat = isTabletop ? (videoAreaTop - Self.tabletopTopBarInset + 8) : 0 #if os(iOS) if let backend = playerService?.currentBackend, @@ -1162,6 +1201,8 @@ extension ExpandedPlayerSheet { videoAreaTop: videoAreaTop, videoAreaHeight: videoAreaHeight, videoFitHeight: videoFitHeight, + topBarOffset: topBarOffset, + horizontalSafeArea: horizontalSafeArea, onVolumeChanged: { [weak appEnvironment] volume in playerService.currentBackend?.volume = volume appEnvironment?.settingsManager.playerVolume = volume @@ -1201,7 +1242,7 @@ extension ExpandedPlayerSheet { playerService.selectEmbeddedSubtitleTrack(trackID) }, panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0, - isPanscanAllowed: !isPortraitPanelVisible, + isPanscanAllowed: !isPortraitPanelVisible && !isTabletop, onTogglePanscan: { [weak navigationCoordinator] in navigationCoordinator?.togglePanscan() }, diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 41072e6b..ed3f70bb 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -107,6 +107,10 @@ struct ExpandedPlayerSheet: View { #if os(iOS) /// Track previous orientation to detect changes @State private var previousIsLandscape: Bool? + /// Foldable hinge partially open (iPhone Duo laptop position): the + /// fullscreen portrait layout keeps the video on the upper half and + /// moves every control to the lower half. + @State var isTabletopHinge = false #endif #if os(iOS) || os(macOS) @@ -362,6 +366,9 @@ struct ExpandedPlayerSheet: View { // Disable animations during layout switch to prevent position interpolation transaction.animation = nil } + #if os(iOS) + .observeHingeLayout(isTabletop: $isTabletopHinge) + #endif #if os(iOS) || os(macOS) .onChange(of: cornerActive) { _, newValue in navigationCoordinator?.isCornerLayoutActive = newValue diff --git a/Yattee/Views/Player/HingeLayoutObserver.swift b/Yattee/Views/Player/HingeLayoutObserver.swift new file mode 100644 index 00000000..ffd946fe --- /dev/null +++ b/Yattee/Views/Player/HingeLayoutObserver.swift @@ -0,0 +1,48 @@ +// +// HingeLayoutObserver.swift +// Yattee +// +// Observes the foldable device hinge (iPhone Duo) and reports whether the +// player should use the "tabletop" layout: hinge partially open, device +// resting like a laptop, video on the upper half and controls on the lower. +// + +import SwiftUI + +#if os(iOS) + +struct HingeLayoutObserver: ViewModifier { + @Binding var isTabletop: Bool + + /// Debug override: launch with `YATTEE_FORCE_TABLETOP=1` to force the + /// tabletop layout on simulators without a hinge control. + private static let forceTabletop = ProcessInfo.processInfo.environment["YATTEE_FORCE_TABLETOP"] == "1" + + func body(content: Content) -> some View { + if Self.forceTabletop { + content.onAppear { isTabletop = true } + } else if #available(iOS 27.1, *) { + content.onHingeChange { _, context in + // `hinge` is nil on devices without a hinge and when the view + // leaves a hierarchy that provides hinge updates. + let tabletop = context.hinge?.status == .partiallyOpen + guard tabletop != isTabletop else { return } + withAnimation(.easeInOut(duration: 0.3)) { + isTabletop = tabletop + } + } + } 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) -> some View { + modifier(HingeLayoutObserver(isTabletop: isTabletop)) + } +} + +#endif diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 7ce4c61c..5f281695 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -67,6 +67,13 @@ struct PlayerControlsView: View { var videoAreaHeight: CGFloat? = nil /// Video fit height for slider sizing (stable during drag, unlike videoAreaHeight) var videoFitHeight: CGFloat? = nil + /// Pushes the top bar down from the screen top (tabletop layout on a + /// foldable: every control lives on the lower half) + var topBarOffset: CGFloat = 0 + /// Horizontal safe area of the hosting view (leading, trailing) for the + /// portrait fullscreen layout, which spans the full window width. Passed + /// in because the controls layer itself ignores the safe area. + var horizontalSafeArea: (leading: CGFloat, trailing: CGFloat) = (0, 0) /// Callback when volume changes (value 0.0-1.0) var onVolumeChanged: ((Float) -> Void)? = nil @@ -536,6 +543,13 @@ struct PlayerControlsView: View { // Just use base padding for aesthetics leftPadding = basePadding rightPadding = basePadding + } else if horizontalSafeArea.leading > 0 || horizontalSafeArea.trailing > 0 { + // Portrait fullscreen (routed through the widescreen branch) + // spans the full window width: keep the controls out of the + // actual, asymmetric horizontal safe area (vertical status + // bar on the iPhone Duo exterior display) + leftPadding = basePadding + horizontalSafeArea.leading + rightPadding = basePadding + horizontalSafeArea.trailing } else { // Panel not visible or not pinned - controls handle all safe areas (symmetric with max) let safeAreaPadding = max(safeArea.left, safeArea.right) + 4 @@ -579,7 +593,7 @@ struct PlayerControlsView: View { // Top bar - aligned to screen top (stays fixed regardless of video area) VStack { topBar - .padding(.top, topPadding) + .padding(.top, topPadding + topBarOffset) .padding(.leading, leftPadding) .padding(.trailing, rightPadding) .applyCornerAdaptationOffset()