mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The built-in presets now use the corner layout, which still falls back to stacked where it doesn't fit (iPhone, iPad portrait, narrow windows). In the corner layout the landscape panels open visible unless the preset says otherwise, on devices that can show it: iPad, macOS, and foldable iPhones, whose hinge is now remembered once reported. Custom presets and presets saved without panel settings keep the stacked layout.
115 lines
4.4 KiB
Swift
115 lines
4.4 KiB
Swift
//
|
||
// 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.
|
||
static let forceTabletop = ProcessInfo.processInfo.environment["YATTEE_FORCE_TABLETOP"] == "1"
|
||
|
||
/// Last reported tabletop state, outliving the player sheet: re-presenting
|
||
/// the player seeds from it so the layout doesn't animate in from the
|
||
/// unfolded state.
|
||
@MainActor static var lastKnownTabletop = false
|
||
|
||
/// The first report after appearing corrects the seed without animation.
|
||
@State private var hasReceivedReport = false
|
||
|
||
private func apply(_ tabletop: Bool) {
|
||
Self.lastKnownTabletop = tabletop
|
||
let animated = hasReceivedReport
|
||
hasReceivedReport = true
|
||
guard tabletop != isTabletop else { return }
|
||
if animated {
|
||
withAnimation(.easeInOut(duration: 0.3)) { isTabletop = tabletop }
|
||
} else {
|
||
var transaction = Transaction()
|
||
transaction.disablesAnimations = true
|
||
withTransaction(transaction) { isTabletop = tabletop }
|
||
}
|
||
}
|
||
|
||
func body(content: Content) -> some View {
|
||
if Self.forceTabletop {
|
||
// The override stands in for a half-open hinge, which only exists
|
||
// on the interior display: folded (exterior, 466×678) keeps the
|
||
// regular layout, like the real `.closed` state would.
|
||
content.onGeometryChange(for: Bool.self) { proxy in
|
||
min(proxy.size.width, proxy.size.height) >= ExpandedPlayerSheet.minHeightForCornerLayout
|
||
} action: { isInterior in
|
||
// Mirror the real hinge state: no programmatic rotation (and no
|
||
// orientation lock) while the hinge is open.
|
||
OrientationManager.shared.canRotateProgrammatically = !isInterior
|
||
apply(isInterior)
|
||
}
|
||
} 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.
|
||
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
|
||
}
|
||
// nil when leaving the hierarchy: keep the last state then
|
||
guard let status = context.hinge?.status else { return }
|
||
PanelSettings.deviceHasHinge = true
|
||
apply(status == .partiallyOpen)
|
||
}
|
||
} else {
|
||
content
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
PanelSettings.deviceHasHinge = 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
|