From b1625afb4a982e599b4e8faf931365ae4a49c323 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 24 Sep 2026 22:21:47 +0200 Subject: [PATCH] Collapse the corner side column by overdrag and fix the fast reveal glitch Overdragging the side column's resize grabber past its minimum width now slides the column off the trailing edge (the video grows with it), and a swipe in from the trailing screen edge brings it back following the finger. The seek pan skips that edge strip while the column is collapsed. Showing the panel again restores every region hidden by its own drag handle. Swiping the below-video panel back up right after hiding it showed the video clipped to a short centered box that sprang back to full size. The hide flipped to the hidden layout in a `.logicallyComplete` completion while the slide spring was still registered on the SwiftUI host of the mpv render view; a reveal drag interrupting it made the release spring start from a bogus shrunken rect. All slide-off completions now wait for `.removed` and bail if a new drag took over. The visibility-keyed `.animation` modifiers key on a toggle generation bumped only by the animated show/hide paths, so drag flips never start the toggle easing. The partnerless `matchedGeometryEffect` on the widescreen video frame is dropped. --- .../Navigation/NavigationCoordinator.swift | 5 + Yattee/Views/Player/CornerPlayerLayout.swift | 219 ++++++++++++++++-- .../Player/ExpandedPlayerSheet+Layouts.swift | 1 - .../Gestures/PlayerGestureCoordinator.swift | 8 + .../Gestures/PlayerGestureOverlay.swift | 8 +- .../Views/Player/Panels/InfoPanelPage.swift | 1 + .../Player/Panels/PanelResizeGrabber.swift | 22 +- .../Player/Panels/RelatedPanelPage.swift | 1 + Yattee/Views/Player/PlayerControlsView.swift | 3 + 9 files changed, 249 insertions(+), 19 deletions(-) diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index cc0e9ba1..21c68659 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -167,6 +167,11 @@ final class NavigationCoordinator { /// otherwise be cancelled and dismiss the player instead). var panelResizeGrabberFrame: CGRect = .zero + /// Corner layout: width of the trailing screen-edge strip reserved for + /// the side column reveal swipe while the column is collapsed (0 when + /// not collapsed) — the seek pan must not begin from inside it. + var cornerSideColumnEdgeRevealInset: CGFloat = 0 + /// Progress bar frame in screen coordinates (for gesture conflict resolution). var progressBarFrame: CGRect = .zero diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index 76668fe0..9cadfa24 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -85,6 +85,27 @@ struct CornerPlayerLayout: View { /// layout, positioned by `belowPanelDragOffset` counting down from full /// hide travel so it follows the finger. @State private var isRevealDragActive = false + /// Side column collapsed by overdragging its resize grabber past the + /// minimum width — independent of `isPanelVisible`, like the below panel. + @State private var isSideColumnVisible = true + /// Overdrag travel of the side column toward the trailing edge (0 = home). + @State private var sideColumnDragOffset: CGFloat = 0 + @State private var isSideColumnOverdragActive = false + /// Edge-swipe reveal in progress: the column renders in the shown layout, + /// positioned by `sideColumnDragOffset` counting down from full travel. + @State private var isSideColumnRevealDragActive = false + /// Bumped by the animated show/hide paths (toggle button, settings sync). + /// The visibility-driven `.animation` modifiers key on this instead of the + /// visibility flags themselves: drag hide/reveal flips those flags in a + /// disabled transaction, but `.animation(_:value:)` ignores that and would + /// still animate the frame height — an animation a quick follow-up swipe + /// then interrupts, leaving the video clipped to a shrunken box that + /// springs back (the "fast swipe" reveal glitch). + @State private var panelToggleGeneration = 0 + + /// Trailing screen-edge strip that starts the reveal swipe while the + /// column is collapsed; drags from anywhere else keep seeking. + static var sideColumnEdgeRevealInset: CGFloat { 20 } private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } /// Suppresses the leaf-level .animation modifiers while a panel drag @@ -165,8 +186,15 @@ struct CornerPlayerLayout: View { let width = max(1, geometry.size.width) let height = max(1, geometry.size.height) - let showsSideColumn = isPanelVisible && hasSideColumn + let showsSideColumn = isPanelVisible && isSideColumnVisible && hasSideColumn + let isSideColumnCollapsed = isPanelVisible && !isSideColumnVisible && hasSideColumn + // Reveal drag renders the shown layout so the column can follow + // the finger while `isSideColumnVisible` is still false. + let showsSideColumnLayout = showsSideColumn || (isSideColumnCollapsed && isSideColumnRevealDragActive) let showsBelowVideo = isPanelVisible && isBelowPanelVisible && hasBelowVideoInfo + // What the player actually sees: every region can be dragged + // 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 @@ -174,7 +202,15 @@ struct CornerPlayerLayout: View { let grabberWidth = Self.grabberWidth let totalColumnWidth = effectiveColumnWidth + grabberWidth - let videoWidth = showsSideColumn ? max(1, width - totalColumnWidth) : width + // Grabber overdrag past the minimum width slides the column off + // the trailing edge; the video grows with the slide so the offset + // layout at full travel matches the collapsed layout exactly (the + // state flips after the animation without a visible jump). + let sideColumnHideTravel = totalColumnWidth + geometry.safeAreaInsets.trailing + let sideColumnSlide = showsSideColumnLayout ? min(sideColumnHideTravel, max(0, sideColumnDragOffset)) : 0 + let videoWidth = showsSideColumnLayout + ? max(1, width - totalColumnWidth + min(totalColumnWidth, sideColumnSlide)) + : width let shownVideoHeight: CGFloat = { let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0) let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0) @@ -237,7 +273,7 @@ struct CornerPlayerLayout: View { GeometryReader { videoProxy in playerContent( togglePanel, - isPanelVisible, + showsAnyRegion, max(1, videoProxy.size.width), max(1, videoProxy.size.height) ) @@ -248,10 +284,10 @@ struct CornerPlayerLayout: View { // visibility state, NOT to derived geometry — appear // settling must not animate. .frame(height: videoHeight) - .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible) - .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration) .frame(width: videoWidth) - .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset) .clipped() .offset(y: videoDragYOffset) .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoDragYOffset) @@ -296,9 +332,12 @@ struct CornerPlayerLayout: View { } else { // Snap back off-screen, then drop the // reveal layout without animation - withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) { + withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) { belowPanelDragOffset = panelHideTravel } completion: { + // A new reveal drag may have taken over + guard isRevealDragActive, belowPanelDragOffset == panelHideTravel, + appEnvironment?.navigationCoordinator.isPanelDragging != true else { return } var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { @@ -310,6 +349,64 @@ struct CornerPlayerLayout: View { } : nil ) + // Swipe in from the trailing screen edge to bring a + // collapsed side column back — the column follows + // the finger via sideColumnDragOffset counting down + // from full travel. Only the edge strip: the seek + // pan is told to skip it, everywhere else seeks. + .simultaneousGesture( + isSideColumnCollapsed + ? DragGesture(coordinateSpace: .local) + .onChanged { value in + guard appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders != true else { return } + guard isSideColumnRevealDragActive + || (value.startLocation.x >= videoWidth - Self.sideColumnEdgeRevealInset + && value.translation.width < 0) else { return } + if appEnvironment?.navigationCoordinator.isPanelDragging != true { + appEnvironment?.navigationCoordinator.isPanelDragging = true + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isSideColumnRevealDragActive = true + sideColumnDragOffset = min(sideColumnHideTravel, max(0, sideColumnHideTravel + value.translation.width)) + } + } + .onEnded { value in + guard isSideColumnRevealDragActive else { return } + appEnvironment?.navigationCoordinator.isPanelDragging = false + if value.translation.width < -80 || value.predictedEndTranslation.width < -160 { + // Revealing: flip to visible at the + // current position, then animate home + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isSideColumnVisible = true + isSideColumnRevealDragActive = false + } + withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { + sideColumnDragOffset = 0 + } + } else { + // Snap back off-screen, then drop the + // reveal layout without animation + withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) { + sideColumnDragOffset = sideColumnHideTravel + } completion: { + // A new reveal drag may have taken over + guard isSideColumnRevealDragActive, sideColumnDragOffset == sideColumnHideTravel, + appEnvironment?.navigationCoordinator.isPanelDragging != true else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isSideColumnRevealDragActive = false + sideColumnDragOffset = 0 + } + } + } + } + : nil + ) .frame(maxHeight: .infinity, alignment: .top) if showsPanelLayout, let video { @@ -327,6 +424,8 @@ struct CornerPlayerLayout: View { .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: isBelowPanelExpanded) .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: belowPanelExpandOffset) .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset) // Pure slide, no fade — with a fade the panel // seems to vanish before the side column and the // video resize, breaking the one-transition feel @@ -340,7 +439,7 @@ struct CornerPlayerLayout: View { // reach the true bottom edge .ignoresSafeArea(edges: .bottom) - if showsSideColumn, let video { + if showsSideColumnLayout, let video { HStack(spacing: 0) { PanelResizeGrabber( panelWidth: $columnWidth, @@ -349,7 +448,54 @@ struct CornerPlayerLayout: View { panelSide: .right, isPinned: true, isDragActive: $isGrabberActive, - hitWidth: grabberWidth + hitWidth: grabberWidth, + onOverdragChanged: { overshoot in + guard overshoot > 0 || isSideColumnOverdragActive else { return } + if appEnvironment?.navigationCoordinator.isPanelDragging != true { + appEnvironment?.navigationCoordinator.isPanelDragging = true + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isSideColumnOverdragActive = true + sideColumnDragOffset = min(sideColumnHideTravel, overshoot) + } + }, + onOverdragEnded: { overshoot, predicted in + guard isSideColumnOverdragActive else { return } + isSideColumnOverdragActive = false + appEnvironment?.navigationCoordinator.isPanelDragging = false + if overshoot > 80 || predicted > 160 { + // Slide fully off (video grows with + // it), then flip the collapsed state + // without animation — like the + // below panel's hide. `.removed`, not + // `.logicallyComplete`: flipping while + // the spring is still registered on the + // video frame leaves a stale animation + // that a quick follow-up reveal swipe + // interrupts, and the release spring + // then starts from a bogus shrunken + // rect (video clipped, growing back). + withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) { + sideColumnDragOffset = sideColumnHideTravel + } completion: { + // The grabber may have been picked up again + guard isSideColumnVisible, !isSideColumnOverdragActive, + sideColumnDragOffset == sideColumnHideTravel else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isSideColumnVisible = false + sideColumnDragOffset = 0 + } + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + sideColumnDragOffset = 0 + } + } + } ) sideColumnPanel(video: video) @@ -358,14 +504,24 @@ struct CornerPlayerLayout: View { // Bottom only — the background must not run under // the system status bar at the top .background(panelBackgroundColor.ignoresSafeArea(edges: .bottom)) + // Overdrag slide: the column keeps its size while its + // layout slot shrinks from the trailing side, so its + // leading edge tracks the growing video; travel into + // the trailing safe area continues as a plain offset. + .offset(x: max(0, sideColumnSlide - totalColumnWidth)) + .frame(width: max(0, totalColumnWidth - sideColumnSlide), alignment: .leading) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset) .transition(.move(edge: .trailing).animation(.easeInOut(duration: panelToggleDuration))) } } // Container-level animation so panel insert/remove and the // video-area resize play as one transition (the host sheet // strips withAnimation transactions, so key it here) - .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible) - .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: panelToggleGeneration) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: sideColumnDragOffset) + } + .onChange(of: isSideColumnCollapsed, initial: true) { _, collapsed in + appEnvironment?.navigationCoordinator.cornerSideColumnEdgeRevealInset = collapsed ? Self.sideColumnEdgeRevealInset : 0 } .onChange(of: width) { _, newWidth in // Re-clamp when the window shrinks (still above the corner @@ -373,6 +529,9 @@ struct CornerPlayerLayout: View { columnWidth = clampColumnWidth(columnWidth, availableWidth: newWidth) } } + .onDisappear { + appEnvironment?.navigationCoordinator.cornerSideColumnEdgeRevealInset = 0 + } .onAppear { // Only sync from the setting once the session's default // visibility was applied — before that the setting is stale @@ -395,7 +554,9 @@ struct CornerPlayerLayout: View { // must reach the local state too. guard let newValue, newValue != isPanelVisible else { return } withAnimation(.easeInOut(duration: 0.25)) { + if newValue { restoreDraggedAwayRegions() } isPanelVisible = newValue + panelToggleGeneration += 1 } } } @@ -463,10 +624,18 @@ struct CornerPlayerLayout: View { // Slide the panel fully off (video recenters via the // drag-progress offset), then flip the hidden state // without animation — the offset layout at full - // travel matches the hidden layout exactly. - withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) { + // travel matches the hidden layout exactly. Must wait + // for `.removed`: flipping at `.logicallyComplete` + // leaves the spring registered on the video frame, and + // a quick follow-up reveal swipe that interrupts it + // makes the release spring start from a bogus shrunken + // rect (video clipped to a short box, growing back). + withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .removed) { belowPanelDragOffset = hideTravel } completion: { + // The panel may have been grabbed again meanwhile + guard isBelowPanelVisible, belowPanelDragOffset == hideTravel, + appEnvironment?.navigationCoordinator.isPanelDragging != true else { return } var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { @@ -557,12 +726,30 @@ struct CornerPlayerLayout: View { // MARK: - Actions private func togglePanel() { + // Regions dragged away on their own leave the panel nominally + // visible — once nothing is left on screen the toggle shows + // everything again instead of "hiding" an empty panel. + let showsAnyRegion = isPanelVisible + && ((isSideColumnVisible && hasSideColumn) || (isBelowPanelVisible && hasBelowVideoInfo)) + let show = !showsAnyRegion withAnimation(.easeInOut(duration: 0.25)) { - isPanelVisible.toggle() - settingsManager?.landscapeDetailsPanelVisible = isPanelVisible - settingsManager?.detailsPanelLastVisible = isPanelVisible + if show { restoreDraggedAwayRegions() } + isPanelVisible = show + panelToggleGeneration += 1 + settingsManager?.landscapeDetailsPanelVisible = show + settingsManager?.detailsPanelLastVisible = show } } + + /// Showing the panel brings back every region hidden by its own drag + /// handle (the portrait layout shares one flag for both, so this keeps + /// the toggle's meaning the same across layouts). + private func restoreDraggedAwayRegions() { + isSideColumnVisible = true + sideColumnDragOffset = 0 + isBelowPanelVisible = true + belowPanelDragOffset = 0 + } } #endif diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 44f64d7d..de274800 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -1507,7 +1507,6 @@ extension ExpandedPlayerSheet { } } .frame(width: videoWidth, height: videoHeight) - .matchedGeometryEffect(id: "player", in: playerNamespace) .geometryGroup() .clipped() diff --git a/Yattee/Views/Player/Gestures/PlayerGestureCoordinator.swift b/Yattee/Views/Player/Gestures/PlayerGestureCoordinator.swift index 1b7857e8..614b60b7 100644 --- a/Yattee/Views/Player/Gestures/PlayerGestureCoordinator.swift +++ b/Yattee/Views/Player/Gestures/PlayerGestureCoordinator.swift @@ -29,6 +29,9 @@ final class PlayerGestureCoordinator: NSObject, UIGestureRecognizerDelegate { var isPinchGestureActive: (() -> Bool)? /// Returns true if panel drag is active (blocks seek gesture). var isPanelDragging: (() -> Bool)? + /// Width of the trailing-edge strip reserved for a panel reveal swipe + /// (0 = none) — pans starting inside it never become seek gestures. + var trailingEdgeRevealInset: (() -> CGFloat)? /// Called when seek gesture is recognized (after activation threshold). var onSeekGestureStarted: (() -> Void)? @@ -193,6 +196,11 @@ final class PlayerGestureCoordinator: NSObject, UIGestureRecognizerDelegate { if isPinchGestureActive?() == true { return false } // Block seek gesture if panel drag is active if isPanelDragging?() == true { return false } + // Leave the trailing screen edge to the panel reveal swipe + if let inset = trailingEdgeRevealInset?(), inset > 0 { + let location = gestureRecognizer.location(in: gestureRecognizer.view) + if location.x >= bounds.width - inset { return false } + } return isActive && seekSettings.isEnabled && isSeekable } diff --git a/Yattee/Views/Player/Gestures/PlayerGestureOverlay.swift b/Yattee/Views/Player/Gestures/PlayerGestureOverlay.swift index aad2e44a..0c1c6c11 100644 --- a/Yattee/Views/Player/Gestures/PlayerGestureOverlay.swift +++ b/Yattee/Views/Player/Gestures/PlayerGestureOverlay.swift @@ -23,6 +23,8 @@ struct PlayerGestureOverlay: View { var isPinchGestureActive: (() -> Bool)? = nil /// Returns true if panel drag is active (blocks seek gesture). var isPanelDragging: (() -> Bool)? = nil + /// Width of the trailing-edge strip reserved for a panel reveal swipe. + var trailingEdgeRevealInset: (() -> CGFloat)? = nil var body: some View { GeometryReader { geometry in @@ -41,7 +43,8 @@ struct PlayerGestureOverlay: View { onSeekGestureChanged: onSeekGestureChanged, onSeekGestureEnded: onSeekGestureEnded, isPinchGestureActive: isPinchGestureActive, - isPanelDragging: isPanelDragging + isPanelDragging: isPanelDragging, + trailingEdgeRevealInset: trailingEdgeRevealInset ) } // Always allow hit testing - single tap to toggle controls should work @@ -65,6 +68,7 @@ private struct GestureRecognizerView: UIViewRepresentable { let onSeekGestureEnded: (CGFloat) -> Void var isPinchGestureActive: (() -> Bool)? = nil var isPanelDragging: (() -> Bool)? = nil + var trailingEdgeRevealInset: (() -> CGFloat)? = nil func makeCoordinator() -> PlayerGestureCoordinator { PlayerGestureCoordinator( @@ -87,6 +91,7 @@ private struct GestureRecognizerView: UIViewRepresentable { coordinator.onSeekGestureEnded = onSeekGestureEnded coordinator.isPinchGestureActive = isPinchGestureActive coordinator.isPanelDragging = isPanelDragging + coordinator.trailingEdgeRevealInset = trailingEdgeRevealInset coordinator.attach(to: view) return view @@ -108,6 +113,7 @@ private struct GestureRecognizerView: UIViewRepresentable { coordinator.onSeekGestureEnded = onSeekGestureEnded coordinator.isPinchGestureActive = isPinchGestureActive coordinator.isPanelDragging = isPanelDragging + coordinator.trailingEdgeRevealInset = trailingEdgeRevealInset } static func dismantleUIView(_ uiView: UIView, coordinator: PlayerGestureCoordinator) { diff --git a/Yattee/Views/Player/Panels/InfoPanelPage.swift b/Yattee/Views/Player/Panels/InfoPanelPage.swift index 08c97a46..c2404922 100644 --- a/Yattee/Views/Player/Panels/InfoPanelPage.swift +++ b/Yattee/Views/Player/Panels/InfoPanelPage.swift @@ -310,6 +310,7 @@ struct InfoPanelPage: View { VideoRowView( video: relatedVideo, style: .regular, + disableInternalTapHandling: true, showThumbnail: true ) .tappableVideo( diff --git a/Yattee/Views/Player/Panels/PanelResizeGrabber.swift b/Yattee/Views/Player/Panels/PanelResizeGrabber.swift index 7e17b8ac..1049b22c 100644 --- a/Yattee/Views/Player/Panels/PanelResizeGrabber.swift +++ b/Yattee/Views/Player/Panels/PanelResizeGrabber.swift @@ -23,6 +23,15 @@ struct PanelResizeGrabber: View { /// Width of the grabber hit area (hosts can narrow it, e.g. corner layout) var hitWidth: CGFloat = PanelResizeGrabber.grabberHitWidth + /// Overdrag past `minWidth`: reports how far (in points, ≥ 0) the finger + /// went beyond the minimum while the width stays clamped — hosts can + /// slide the panel off-screen with it. Reported every drag update, so a + /// finger coming back above the minimum reports 0 again. + var onOverdragChanged: ((CGFloat) -> Void)? = nil + /// Release after an overdrag: final overshoot and the predicted overshoot + /// (from the drag's projected end location) for flick detection. + var onOverdragEnded: ((_ overshoot: CGFloat, _ predictedOvershoot: CGFloat) -> Void)? = nil + @State private var isDragging = false @State private var dragStartWidth: CGFloat? = nil @State private var dragStartLocation: CGFloat? = nil @@ -114,8 +123,19 @@ struct PanelResizeGrabber: View { let newWidth = startWidth + delta panelWidth = min(max(newWidth, minWidth), maxWidth) + onOverdragChanged?(max(0, minWidth - newWidth)) } - .onEnded { _ in + .onEnded { value in + if let onOverdragEnded, + let startWidth = dragStartWidth, + let startLocation = dragStartLocation { + let overshoot = { (locationX: CGFloat) -> CGFloat in + let locationDelta = locationX - startLocation + let delta = panelSide == .right ? -locationDelta : locationDelta + return max(0, minWidth - (startWidth + delta)) + } + onOverdragEnded(overshoot(value.location.x), overshoot(value.predictedEndLocation.x)) + } dragStartWidth = nil dragStartLocation = nil isDragging = false diff --git a/Yattee/Views/Player/Panels/RelatedPanelPage.swift b/Yattee/Views/Player/Panels/RelatedPanelPage.swift index 0f3ce1e9..6e01c737 100644 --- a/Yattee/Views/Player/Panels/RelatedPanelPage.swift +++ b/Yattee/Views/Player/Panels/RelatedPanelPage.swift @@ -35,6 +35,7 @@ struct RelatedPanelPage: View { VideoRowView( video: relatedVideo, style: .regular, + disableInternalTapHandling: true, showThumbnail: settings.effectiveShowThumbnails ) .tappableVideo( diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 5564c354..fdcbfeb5 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -1130,6 +1130,9 @@ struct PlayerControlsView: View { }, isPanelDragging: { [weak appEnvironment] in appEnvironment?.navigationCoordinator.isPanelDragging ?? false + }, + trailingEdgeRevealInset: { [weak appEnvironment] in + appEnvironment?.navigationCoordinator.cornerSideColumnEdgeRevealInset ?? 0 } ) }