mirror of
https://github.com/yattee/yattee.git
synced 2026-11-06 11:57:18 +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)?
|
let onChannelTap: (() -> Void)?
|
||||||
var onFullscreen: (() -> Void)? = nil
|
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
|
/// Seed the panel visibility BEFORE the first frame — applying the
|
||||||
/// preset's default visibility via onAppear/onChange animates the panels
|
/// preset's default visibility via onAppear/onChange animates the panels
|
||||||
@@ -44,8 +48,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
|||||||
_ height: CGFloat
|
_ height: CGFloat
|
||||||
) -> PlayerContent,
|
) -> PlayerContent,
|
||||||
onChannelTap: (() -> Void)?,
|
onChannelTap: (() -> Void)?,
|
||||||
onFullscreen: (() -> Void)? = nil
|
onFullscreen: (() -> Void)? = nil,
|
||||||
|
isHingeSplit: Bool = false
|
||||||
) {
|
) {
|
||||||
|
self.isHingeSplit = isHingeSplit
|
||||||
self.playerControlsLayout = playerControlsLayout
|
self.playerControlsLayout = playerControlsLayout
|
||||||
self.playerContent = playerContent
|
self.playerContent = playerContent
|
||||||
self.onChannelTap = onChannelTap
|
self.onChannelTap = onChannelTap
|
||||||
@@ -179,9 +185,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
|
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
|
||||||
|
// Up to half the window, grabber included
|
||||||
let maxWidth = max(
|
let maxWidth = max(
|
||||||
Self.minColumnWidth,
|
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)
|
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.
|
// away on its own while the panel stays nominally visible.
|
||||||
let showsAnyRegion = showsSideColumn || showsBelowVideo
|
let showsAnyRegion = showsSideColumn || showsBelowVideo
|
||||||
|
|
||||||
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
|
|
||||||
// Grabber sits on the column's leading edge, inside the column
|
// Grabber sits on the column's leading edge, inside the column
|
||||||
// background — narrow strip matching the panel drag handle look.
|
// background — narrow strip matching the panel drag handle look.
|
||||||
let grabberWidth = Self.grabberWidth
|
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
|
let totalColumnWidth = effectiveColumnWidth + grabberWidth
|
||||||
|
|
||||||
// Panels never move. Both sit at their home positions (column on
|
// Panels never move. Both sit at their home positions (column on
|
||||||
@@ -315,9 +324,9 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
|||||||
if hasSideColumn, let video {
|
if hasSideColumn, let video {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
PanelResizeGrabber(
|
PanelResizeGrabber(
|
||||||
panelWidth: $columnWidth,
|
panelWidth: isHingeSplit ? .constant(hingeColumnWidth) : $columnWidth,
|
||||||
minWidth: Self.minColumnWidth,
|
minWidth: isHingeSplit ? hingeColumnWidth : Self.minColumnWidth,
|
||||||
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
|
maxWidth: isHingeSplit ? hingeColumnWidth : clampColumnWidth(.infinity, availableWidth: width),
|
||||||
panelSide: .right,
|
panelSide: .right,
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
isDragActive: $isGrabberActive,
|
isDragActive: $isGrabberActive,
|
||||||
|
|||||||
@@ -1364,10 +1364,20 @@ extension ExpandedPlayerSheet {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
self.toggleFullscreen()
|
self.toggleFullscreen()
|
||||||
#endif
|
#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
|
// MARK: - Widescreen Player Area
|
||||||
|
|
||||||
/// Player area for widescreen layout with panel support.
|
/// Player area for widescreen layout with panel support.
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ struct ExpandedPlayerSheet: View {
|
|||||||
/// Foldable hinge partially open (iPhone Duo laptop position): the
|
/// Foldable hinge partially open (iPhone Duo laptop position): the
|
||||||
/// fullscreen portrait layout keeps the video on the upper half and
|
/// fullscreen portrait layout keeps the video on the upper half and
|
||||||
/// moves every control to the lower half.
|
/// 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
|
#endif
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ struct HingeLayoutObserver: ViewModifier {
|
|||||||
/// tabletop layout on simulators without a hinge control.
|
/// tabletop layout on simulators without a hinge control.
|
||||||
static let forceTabletop = ProcessInfo.processInfo.environment["YATTEE_FORCE_TABLETOP"] == "1"
|
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 {
|
func body(content: Content) -> some View {
|
||||||
if Self.forceTabletop {
|
if Self.forceTabletop {
|
||||||
// The override stands in for a half-open hinge, which only exists
|
// 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
|
// Mirror the real hinge state: no programmatic rotation (and no
|
||||||
// orientation lock) while the hinge is open.
|
// orientation lock) while the hinge is open.
|
||||||
OrientationManager.shared.canRotateProgrammatically = !isInterior
|
OrientationManager.shared.canRotateProgrammatically = !isInterior
|
||||||
guard isInterior != isTabletop else { return }
|
apply(isInterior)
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
|
||||||
isTabletop = isInterior
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if #available(iOS 27.1, *) {
|
} else if #available(iOS 27.1, *) {
|
||||||
content.onHingeChange { _, context in
|
content.onHingeChange { _, context in
|
||||||
@@ -43,11 +62,9 @@ struct HingeLayoutObserver: ViewModifier {
|
|||||||
// programmatic rotation, so the player must not offer it.
|
// programmatic rotation, so the player must not offer it.
|
||||||
OrientationManager.shared.canRotateProgrammatically = status == .closed
|
OrientationManager.shared.canRotateProgrammatically = status == .closed
|
||||||
}
|
}
|
||||||
let tabletop = context.hinge?.status == .partiallyOpen
|
// nil when leaving the hierarchy: keep the last state then
|
||||||
guard tabletop != isTabletop else { return }
|
guard let status = context.hinge?.status else { return }
|
||||||
withAnimation(.easeInOut(duration: 0.3)) {
|
apply(status == .partiallyOpen)
|
||||||
isTabletop = tabletop
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
|
|||||||
Reference in New Issue
Block a user