mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Split the corner layout on the hinge when partially open
On a half-open foldable the side column takes exactly the trailing half, so the split lands on the hinge. Fully open, the column can now be resized up to half the width. A re-presented player seeds the hinge state from the last report so it doesn't animate into the split.
This commit is contained in:
@@ -28,6 +28,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
|
||||
let onChannelTap: (() -> Void)?
|
||||
var onFullscreen: (() -> Void)? = nil
|
||||
/// Foldable hinge partially open (iPhone Duo, book posture): the side
|
||||
/// column (grabber included) takes exactly the trailing half so the
|
||||
/// split lands on the hinge. Not resizable then — only overdrag-hide.
|
||||
var isHingeSplit = false
|
||||
|
||||
/// Seed the panel visibility BEFORE the first frame — applying the
|
||||
/// preset's default visibility via onAppear/onChange animates the panels
|
||||
@@ -44,8 +48,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
_ height: CGFloat
|
||||
) -> PlayerContent,
|
||||
onChannelTap: (() -> Void)?,
|
||||
onFullscreen: (() -> Void)? = nil
|
||||
onFullscreen: (() -> Void)? = nil,
|
||||
isHingeSplit: Bool = false
|
||||
) {
|
||||
self.isHingeSplit = isHingeSplit
|
||||
self.playerControlsLayout = playerControlsLayout
|
||||
self.playerContent = playerContent
|
||||
self.onChannelTap = onChannelTap
|
||||
@@ -179,9 +185,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
}
|
||||
|
||||
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
|
||||
// Up to half the window, grabber included
|
||||
let maxWidth = max(
|
||||
Self.minColumnWidth,
|
||||
min(Self.maxColumnWidthCap, availableWidth * 0.45, availableWidth - Self.minVideoColumnWidth)
|
||||
min(Self.maxColumnWidthCap, availableWidth / 2 - Self.grabberWidth, availableWidth - Self.minVideoColumnWidth)
|
||||
)
|
||||
return min(max(width, Self.minColumnWidth), maxWidth)
|
||||
}
|
||||
@@ -227,10 +234,12 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
// away on its own while the panel stays nominally visible.
|
||||
let showsAnyRegion = showsSideColumn || showsBelowVideo
|
||||
|
||||
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
|
||||
// Grabber sits on the column's leading edge, inside the column
|
||||
// background — narrow strip matching the panel drag handle look.
|
||||
let grabberWidth = Self.grabberWidth
|
||||
// Hinge split ignores (and never overwrites) the saved width.
|
||||
let hingeColumnWidth = max(Self.minColumnWidth, width / 2 - grabberWidth)
|
||||
let effectiveColumnWidth = isHingeSplit ? hingeColumnWidth : clampColumnWidth(columnWidth, availableWidth: width)
|
||||
let totalColumnWidth = effectiveColumnWidth + grabberWidth
|
||||
|
||||
// Panels never move. Both sit at their home positions (column on
|
||||
@@ -315,9 +324,9 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
if hasSideColumn, let video {
|
||||
HStack(spacing: 0) {
|
||||
PanelResizeGrabber(
|
||||
panelWidth: $columnWidth,
|
||||
minWidth: Self.minColumnWidth,
|
||||
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
|
||||
panelWidth: isHingeSplit ? .constant(hingeColumnWidth) : $columnWidth,
|
||||
minWidth: isHingeSplit ? hingeColumnWidth : Self.minColumnWidth,
|
||||
maxWidth: isHingeSplit ? hingeColumnWidth : clampColumnWidth(.infinity, availableWidth: width),
|
||||
panelSide: .right,
|
||||
isPinned: true,
|
||||
isDragActive: $isGrabberActive,
|
||||
|
||||
@@ -1364,10 +1364,20 @@ extension ExpandedPlayerSheet {
|
||||
#if os(iOS)
|
||||
self.toggleFullscreen()
|
||||
#endif
|
||||
}
|
||||
},
|
||||
isHingeSplit: isHingeSplitActive
|
||||
)
|
||||
}
|
||||
|
||||
/// The corner layout splits on the hinge while it's partially open.
|
||||
private var isHingeSplitActive: Bool {
|
||||
#if os(iOS)
|
||||
isTabletopHinge
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Widescreen Player Area
|
||||
|
||||
/// Player area for widescreen layout with panel support.
|
||||
|
||||
@@ -100,7 +100,9 @@ struct ExpandedPlayerSheet: View {
|
||||
/// 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
|
||||
/// Seeded from the last hinge report so a re-presented player starts in
|
||||
/// the right layout instead of animating into it.
|
||||
@State var isTabletopHinge = HingeLayoutObserver.lastKnownTabletop
|
||||
#endif
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
@@ -18,6 +18,28 @@ struct HingeLayoutObserver: ViewModifier {
|
||||
/// 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
|
||||
@@ -29,10 +51,7 @@ struct HingeLayoutObserver: ViewModifier {
|
||||
// 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
|
||||
}
|
||||
apply(isInterior)
|
||||
}
|
||||
} else if #available(iOS 27.1, *) {
|
||||
content.onHingeChange { _, context in
|
||||
@@ -43,11 +62,9 @@ struct HingeLayoutObserver: ViewModifier {
|
||||
// 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
|
||||
}
|
||||
// nil when leaving the hierarchy: keep the last state then
|
||||
guard let status = context.hinge?.status else { return }
|
||||
apply(status == .partiallyOpen)
|
||||
}
|
||||
} else {
|
||||
content
|
||||
|
||||
Reference in New Issue
Block a user