diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index eb68cd32..2f455ad4 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -28,6 +28,10 @@ struct CornerPlayerLayout: 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: 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: 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: 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: 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, diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index d3f7de2f..a256fdfa 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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. diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 9316d789..b9a61bd4 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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) diff --git a/Yattee/Views/Player/HingeLayoutObserver.swift b/Yattee/Views/Player/HingeLayoutObserver.swift index 4bd50cbe..879ae37c 100644 --- a/Yattee/Views/Player/HingeLayoutObserver.swift +++ b/Yattee/Views/Player/HingeLayoutObserver.swift @@ -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