Files
yattee/Yattee/Views/Player/HingeLayoutObserver.swift
Arkadiusz Fal e71bb76f13 Default the built-in presets to the corner layout
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.
2026-10-12 18:24:46 +02:00

115 lines
4.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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