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).
This commit is contained in:
Arkadiusz Fal
2026-09-30 22:02:12 +02:00
parent 39041e4b39
commit 99d3d31031
4 changed files with 126 additions and 16 deletions

View File

@@ -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()
},

View File

@@ -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

View File

@@ -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<Bool>) -> some View {
modifier(HingeLayoutObserver(isTabletop: isTabletop))
}
}
#endif

View File

@@ -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()