Files
yattee/Yattee/Views/Player/HingeLayoutObserver.swift
Arkadiusz Fal 40267573a9 Suspend the orientation lock while the Duo hinge is open
The in-app orientation lock pinned the player to the orientation it
opened in even where programmatic rotation is refused, so rotating the
half-open device did nothing. OrientationManager now ignores lock
requests and drops the current lock while canRotateProgrammatically is
false. The YATTEE_FORCE_TABLETOP override only applies on the interior
display size and flags the same state, matching a real .partiallyOpen
hinge instead of forcing the layout on the folded exterior display.
2026-10-01 22:35:47 +02:00

96 lines
3.6 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"
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
guard isInterior != isTabletop else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isTabletop = 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
}
let tabletop = context.hinge?.status == .partiallyOpen
guard tabletop != isTabletop else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isTabletop = tabletop
}
}
} 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
}
} 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