From 65a1b9c6d550af04f1edc3a994f7039fe9d19c07 Mon Sep 17 00:00:00 2001 From: AreChen Date: Thu, 25 Jun 2026 17:53:52 +0800 Subject: [PATCH 1/5] fix: restore macos resize hit areas --- apps/desktop/scripts/verify-shell.mjs | 1 + apps/desktop/src/styles/shell/resize.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/scripts/verify-shell.mjs b/apps/desktop/scripts/verify-shell.mjs index a2ba9ed..c3fc6fd 100644 --- a/apps/desktop/scripts/verify-shell.mjs +++ b/apps/desktop/scripts/verify-shell.mjs @@ -828,6 +828,7 @@ assert.match(styles, /\.history-section/, "styles must include playback history assert.match(styles, /\.history-item/, "styles must include playback history item styling"); assert.match(styles, /\.drag-region\s*\{[\s\S]*inset:\s*0/, "drag region must cover the non-control player surface, not just the title strip"); assert.match(styles, /\.resize-region\s*\{[\s\S]*z-index:\s*95/, "overlay must keep resize hit areas above modal backdrops"); +assert.match(styles, /\.resize-region\s*\{[\s\S]*background:\s*rgba\(\s*0,\s*0,\s*0,\s*0\.004\s*\)/, "transparent overlay resize hit areas must paint a near-invisible hit-test surface so macOS WebView does not pass through to the drag region"); assert.match(styles, /\.resize-region--south-east/, "overlay must include corner resize hit areas"); assert.match(styles, /\.resize-feedback\s*\{[\s\S]*pointer-events:\s*none/, "resize feedback must be visible without blocking media controls or resize hit areas"); assert.match(styles, /\.resize-feedback\s*\{[\s\S]*z-index:\s*96/, "resize feedback must render above transparent resize hit areas so edge hints are complete"); diff --git a/apps/desktop/src/styles/shell/resize.css b/apps/desktop/src/styles/shell/resize.css index 784350d..0182be5 100644 --- a/apps/desktop/src/styles/shell/resize.css +++ b/apps/desktop/src/styles/shell/resize.css @@ -1,7 +1,7 @@ .resize-region { position: absolute; z-index: 95; - background: rgba(0, 0, 0, 0.001); + background: rgba(0, 0, 0, 0.004); pointer-events: auto; } From c6335f14145c444194e102960e2f904f42d95ddb Mon Sep 17 00:00:00 2001 From: AreChen Date: Thu, 25 Jun 2026 18:17:48 +0800 Subject: [PATCH 2/5] fix: harden macos resize edge handling --- apps/desktop/scripts/verify-shell.mjs | 7 +++++++ .../src/components/player/PlayerAppView.tsx | 4 ++-- .../hooks/windowFrameInteractions/resize.ts | 11 +++++++++-- apps/desktop/src/styles/shell/resize.css | 19 +++++++++++-------- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/desktop/scripts/verify-shell.mjs b/apps/desktop/scripts/verify-shell.mjs index c3fc6fd..8892149 100644 --- a/apps/desktop/scripts/verify-shell.mjs +++ b/apps/desktop/scripts/verify-shell.mjs @@ -726,7 +726,11 @@ assert.match(appSource, /setPointerCapture[\s\S]*manualResizeDragRef|manualResiz assert.match(appSource, /requestManualResizeFlush[\s\S]*resizeCommandInFlight/, "manual macOS resize fallback must coalesce pointer deltas and keep only one resize IPC in flight"); assert.match(appSource, /flushManualResizeDelta[\s\S]*applyManualMainWindowResize/, "manual macOS resize fallback must flush coalesced deltas through the backend resize command"); assert.match(appSource, /window_set_resize_cursor/, "resize handles must explicitly set native cursor icons instead of relying only on CSS over transparent macOS overlays"); +assert.match(appSource, /function\s+isMacosResizeRuntime[\s\S]*navigator\.platform[\s\S]*Mac/, "macOS resize fallback must not depend only on async platform support before choosing the manual resize path"); +assert.match(appSource, /isMacosResizeRuntime\(platformOs\)[\s\S]*manualResizeDragRef/, "macOS resize handles must use the manual resize path even before platform support finishes loading"); assert.match(appSource, /(?:onPointerEnter=\{\(event\) => handleResizePointerEnter\(event, region\.direction\)\}|onPointerEnter:\s*handleResizePointerEnter[\s\S]*onPointerEnter=\{\(event\) => resizeRegionHandlers\.onPointerEnter\(event, region\.direction\)\}|onPointerEnter=\{\(event\) => resizeRegionHandlers\.onPointerEnter\(event, region\.direction\)\}[\s\S]*onPointerEnter:\s*handleResizePointerEnter)/, "resize handles must set the matching cursor as soon as the pointer enters the hit area"); +assert.match(appSource, /onPointerMove=\{\(event\) => resizeRegionHandlers\.onPointerMove\(event, region\.direction\)\}/, "resize handles must refresh hover feedback on pointer move, not only on pointer enter"); +assert.match(appSource, /function\s+handleResizePointerMove\([^)]*direction:\s*ResizeDirection[\s\S]*setResizeBoundaryFeedback\(direction\)/, "resize pointer move must keep the boundary hint active while the pointer remains inside a resize hit area"); assert.match(appSource, /(?:onPointerLeave=\{handleResizePointerLeave\}|onPointerLeave:\s*handleResizePointerLeave[\s\S]*onPointerLeave=\{resizeRegionHandlers\.onPointerLeave\}|onPointerLeave=\{resizeRegionHandlers\.onPointerLeave\}[\s\S]*onPointerLeave:\s*handleResizePointerLeave)/, "resize handles must restore the default cursor when the pointer leaves the hit area"); assert.match(appSource, /resizeFeedback/, "resize handles must maintain visible in-app resize feedback state for macOS when native cursor icons are unavailable"); assert.match(appSource, /resize-feedback--active/, "resize dragging must strengthen the in-app resize feedback"); @@ -829,6 +833,9 @@ assert.match(styles, /\.history-item/, "styles must include playback history ite assert.match(styles, /\.drag-region\s*\{[\s\S]*inset:\s*0/, "drag region must cover the non-control player surface, not just the title strip"); assert.match(styles, /\.resize-region\s*\{[\s\S]*z-index:\s*95/, "overlay must keep resize hit areas above modal backdrops"); assert.match(styles, /\.resize-region\s*\{[\s\S]*background:\s*rgba\(\s*0,\s*0,\s*0,\s*0\.004\s*\)/, "transparent overlay resize hit areas must paint a near-invisible hit-test surface so macOS WebView does not pass through to the drag region"); +assert.match(styles, /\.resize-region\s*\{[\s\S]*touch-action:\s*none/, "resize hit areas must keep browser gesture handling from stealing edge drags"); +assert.match(styles, /\.resize-region--east,\s*[\s\S]*\.resize-region--west\s*\{[\s\S]*width:\s*16px/, "side resize hit areas must be wide enough to catch macOS transparent overlay edge drags"); +assert.match(styles, /\.resize-region--north-east,\s*[\s\S]*\.resize-region--south-west\s*\{[\s\S]*width:\s*24px[\s\S]*height:\s*24px/, "corner resize hit areas must be large enough to catch macOS transparent overlay corner drags"); assert.match(styles, /\.resize-region--south-east/, "overlay must include corner resize hit areas"); assert.match(styles, /\.resize-feedback\s*\{[\s\S]*pointer-events:\s*none/, "resize feedback must be visible without blocking media controls or resize hit areas"); assert.match(styles, /\.resize-feedback\s*\{[\s\S]*z-index:\s*96/, "resize feedback must render above transparent resize hit areas so edge hints are complete"); diff --git a/apps/desktop/src/components/player/PlayerAppView.tsx b/apps/desktop/src/components/player/PlayerAppView.tsx index 24262eb..9f871f0 100644 --- a/apps/desktop/src/components/player/PlayerAppView.tsx +++ b/apps/desktop/src/components/player/PlayerAppView.tsx @@ -37,7 +37,7 @@ type ResizeRegionHandlers = { onPointerEnter: (event: PointerEvent, direction: ResizeDirection) => void; onPointerLeave: ComponentProps<"div">["onPointerLeave"]; onPointerDown: (event: PointerEvent, direction: ResizeDirection) => void; - onPointerMove: ComponentProps<"div">["onPointerMove"]; + onPointerMove: (event: PointerEvent, direction: ResizeDirection) => void; onPointerUp: ComponentProps<"div">["onPointerUp"]; onPointerCancel: ComponentProps<"div">["onPointerCancel"]; }; @@ -101,7 +101,7 @@ export function PlayerAppView({ onPointerEnter={(event) => resizeRegionHandlers.onPointerEnter(event, region.direction)} onPointerLeave={resizeRegionHandlers.onPointerLeave} onPointerDown={(event) => resizeRegionHandlers.onPointerDown(event, region.direction)} - onPointerMove={resizeRegionHandlers.onPointerMove} + onPointerMove={(event) => resizeRegionHandlers.onPointerMove(event, region.direction)} onPointerUp={resizeRegionHandlers.onPointerUp} onPointerCancel={resizeRegionHandlers.onPointerCancel} /> diff --git a/apps/desktop/src/hooks/windowFrameInteractions/resize.ts b/apps/desktop/src/hooks/windowFrameInteractions/resize.ts index 1e03f7f..c784d01 100644 --- a/apps/desktop/src/hooks/windowFrameInteractions/resize.ts +++ b/apps/desktop/src/hooks/windowFrameInteractions/resize.ts @@ -11,6 +11,10 @@ type UseWindowResizeRegionsOptions = { onUserActivity: () => void; }; +function isMacosResizeRuntime(platformOs: string | null | undefined) { + return platformOs === "macos" || (!platformOs && typeof navigator !== "undefined" && /Mac/.test(navigator.platform)); +} + export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindowResizeRegionsOptions) { const [resizeFeedback, setResizeFeedback] = useState(null); const manualResizeDragRef = useRef(null); @@ -111,7 +115,7 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow function startMainWindowResize(event: ReactPointerEvent, direction: ResizeDirection) { setNativeResizeCursor(direction); setResizeBoundaryFeedback(direction, true); - if (platformOs === "macos") { + if (isMacosResizeRuntime(platformOs)) { event.currentTarget.setPointerCapture(event.pointerId); manualResizeDragRef.current = { pointerId: event.pointerId, @@ -157,9 +161,12 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow startMainWindowResize(event, direction); } - function handleResizePointerMove(event: ReactPointerEvent) { + function handleResizePointerMove(event: ReactPointerEvent, direction: ResizeDirection) { const pendingResize = manualResizeDragRef.current; if (!pendingResize || pendingResize.pointerId !== event.pointerId) { + event.stopPropagation(); + setNativeResizeCursor(direction); + setResizeBoundaryFeedback(direction); return; } diff --git a/apps/desktop/src/styles/shell/resize.css b/apps/desktop/src/styles/shell/resize.css index 0182be5..00d0802 100644 --- a/apps/desktop/src/styles/shell/resize.css +++ b/apps/desktop/src/styles/shell/resize.css @@ -3,13 +3,16 @@ z-index: 95; background: rgba(0, 0, 0, 0.004); pointer-events: auto; + touch-action: none; + user-select: none; + -webkit-user-drag: none; } .resize-region--north, .resize-region--south { - right: 14px; - left: 14px; - height: 8px; + right: 24px; + left: 24px; + height: 12px; cursor: ns-resize; } @@ -23,9 +26,9 @@ .resize-region--east, .resize-region--west { - top: 14px; - bottom: 14px; - width: 8px; + top: 24px; + bottom: 24px; + width: 16px; cursor: ew-resize; } @@ -41,8 +44,8 @@ .resize-region--north-west, .resize-region--south-east, .resize-region--south-west { - width: 14px; - height: 14px; + width: 24px; + height: 24px; } .resize-region--north-east { From aac2c8602cdfdb8220ff83cc339c31fd2e62e193 Mon Sep 17 00:00:00 2001 From: AreChen Date: Thu, 25 Jun 2026 18:33:23 +0800 Subject: [PATCH 3/5] fix: stabilize macos edge resize capture --- apps/desktop/scripts/verify-shell.mjs | 6 + apps/desktop/src/app/constants/timing.ts | 1 + .../src/components/player/PlayerAppView.tsx | 4 + apps/desktop/src/hooks/useChromeAutoHide.ts | 18 ++- .../src/hooks/usePlayerChromeInteractions.ts | 15 ++- .../src/hooks/useWindowFrameInteractions.ts | 6 + .../hooks/windowFrameInteractions/resize.ts | 109 +++++++++++++++++- 7 files changed, 154 insertions(+), 5 deletions(-) diff --git a/apps/desktop/scripts/verify-shell.mjs b/apps/desktop/scripts/verify-shell.mjs index 8892149..c16e93b 100644 --- a/apps/desktop/scripts/verify-shell.mjs +++ b/apps/desktop/scripts/verify-shell.mjs @@ -731,6 +731,12 @@ assert.match(appSource, /isMacosResizeRuntime\(platformOs\)[\s\S]*manualResizeDr assert.match(appSource, /(?:onPointerEnter=\{\(event\) => handleResizePointerEnter\(event, region\.direction\)\}|onPointerEnter:\s*handleResizePointerEnter[\s\S]*onPointerEnter=\{\(event\) => resizeRegionHandlers\.onPointerEnter\(event, region\.direction\)\}|onPointerEnter=\{\(event\) => resizeRegionHandlers\.onPointerEnter\(event, region\.direction\)\}[\s\S]*onPointerEnter:\s*handleResizePointerEnter)/, "resize handles must set the matching cursor as soon as the pointer enters the hit area"); assert.match(appSource, /onPointerMove=\{\(event\) => resizeRegionHandlers\.onPointerMove\(event, region\.direction\)\}/, "resize handles must refresh hover feedback on pointer move, not only on pointer enter"); assert.match(appSource, /function\s+handleResizePointerMove\([^)]*direction:\s*ResizeDirection[\s\S]*setResizeBoundaryFeedback\(direction\)/, "resize pointer move must keep the boundary hint active while the pointer remains inside a resize hit area"); +assert.match(appSource, /function\s+resizeDirectionFromSurfacePointer[\s\S]*getBoundingClientRect[\s\S]*WINDOW_RESIZE_EDGE_HIT_PX/, "overlay shell must detect resize edges by pointer coordinates instead of relying only on narrow transparent resize div hit-testing"); +assert.match(appSource, /onPointerDownCapture[\s\S]*handleResizeSurfacePointerDown/, "overlay shell must capture edge pointerdown before the full-surface drag region starts window dragging"); +assert.match(appSource, /onPointerMoveCapture[\s\S]*handleResizeSurfacePointerMove/, "overlay shell must refresh resize feedback from capture-phase pointer movement across the full transparent overlay"); +assert.match(appSource, /function\s+handleResizeSurfacePointerMove[\s\S]*manualResizeDragRef\.current[\s\S]*handleResizePointerMove\(event,\s*pendingResize\.direction\)/, "manual macOS resize drags must continue with the captured direction after the pointer leaves the edge hit zone"); +assert.match(appSource, /function\s+isPointerNearViewportResizeEdge[\s\S]*WINDOW_RESIZE_EDGE_HIT_PX/, "global pointerout handling must recognize resize-edge pointer positions before clearing hover feedback"); +assert.match(appSource, /handleWindowPointerExit[\s\S]*isPointerNearViewportResizeEdge\(event\)[\s\S]*return/, "resize hover feedback must not be cleared by window pointerout while the pointer remains on a resize edge"); assert.match(appSource, /(?:onPointerLeave=\{handleResizePointerLeave\}|onPointerLeave:\s*handleResizePointerLeave[\s\S]*onPointerLeave=\{resizeRegionHandlers\.onPointerLeave\}|onPointerLeave=\{resizeRegionHandlers\.onPointerLeave\}[\s\S]*onPointerLeave:\s*handleResizePointerLeave)/, "resize handles must restore the default cursor when the pointer leaves the hit area"); assert.match(appSource, /resizeFeedback/, "resize handles must maintain visible in-app resize feedback state for macOS when native cursor icons are unavailable"); assert.match(appSource, /resize-feedback--active/, "resize dragging must strengthen the in-app resize feedback"); diff --git a/apps/desktop/src/app/constants/timing.ts b/apps/desktop/src/app/constants/timing.ts index 6f58828..466fcb6 100644 --- a/apps/desktop/src/app/constants/timing.ts +++ b/apps/desktop/src/app/constants/timing.ts @@ -13,4 +13,5 @@ export const DEFAULT_VOLUME_STEP = 0.05; export const WINDOW_DRAG_START_DISTANCE_PX = 4; export const WINDOW_DOUBLE_CLICK_MAX_MS = 650; export const WINDOW_DOUBLE_CLICK_MAX_DISTANCE_PX = 24; +export const WINDOW_RESIZE_EDGE_HIT_PX = 24; export const SUBTITLE_DELAY_STEP_SECONDS = 0.1; diff --git a/apps/desktop/src/components/player/PlayerAppView.tsx b/apps/desktop/src/components/player/PlayerAppView.tsx index 9f871f0..ecda0a2 100644 --- a/apps/desktop/src/components/player/PlayerAppView.tsx +++ b/apps/desktop/src/components/player/PlayerAppView.tsx @@ -17,8 +17,12 @@ type ShellHandlers = Pick< | "onDrop" | "onKeyDown" | "onPointerDown" + | "onPointerDownCapture" | "onPointerLeave" | "onPointerMove" + | "onPointerMoveCapture" + | "onPointerUpCapture" + | "onPointerCancelCapture" | "onWheel" >; diff --git a/apps/desktop/src/hooks/useChromeAutoHide.ts b/apps/desktop/src/hooks/useChromeAutoHide.ts index 6a98d70..56d654c 100644 --- a/apps/desktop/src/hooks/useChromeAutoHide.ts +++ b/apps/desktop/src/hooks/useChromeAutoHide.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { AUTO_HIDE_CONTROLS_MS } from "../app/constants"; +import { AUTO_HIDE_CONTROLS_MS, WINDOW_RESIZE_EDGE_HIT_PX } from "../app/constants"; import type { ShortcutAction } from "../app/types"; type UseChromeAutoHideOptions = { @@ -9,6 +9,19 @@ type UseChromeAutoHideOptions = { onPointerExit?: () => void; }; +function isPointerNearViewportResizeEdge(event: MouseEvent | PointerEvent) { + return ( + event.clientX >= 0 && + event.clientY >= 0 && + event.clientX <= window.innerWidth && + event.clientY <= window.innerHeight && + (event.clientX <= WINDOW_RESIZE_EDGE_HIT_PX || + event.clientY <= WINDOW_RESIZE_EDGE_HIT_PX || + window.innerWidth - event.clientX <= WINDOW_RESIZE_EDGE_HIT_PX || + window.innerHeight - event.clientY <= WINDOW_RESIZE_EDGE_HIT_PX) + ); +} + export function useChromeAutoHide({ mediaId, isChromePinned, quietKeyboardControls, onPointerExit }: UseChromeAutoHideOptions) { const [isChromeVisible, setIsChromeVisible] = useState(true); const chromeHideTimerRef = useRef(null); @@ -69,6 +82,9 @@ export function useChromeAutoHide({ mediaId, isChromePinned, quietKeyboardContro if (relatedTarget instanceof Node && document.documentElement.contains(relatedTarget)) { return; } + if (isPointerNearViewportResizeEdge(event)) { + return; + } hideChromeForPointerExit(); }; diff --git a/apps/desktop/src/hooks/usePlayerChromeInteractions.ts b/apps/desktop/src/hooks/usePlayerChromeInteractions.ts index d821601..6afdbac 100644 --- a/apps/desktop/src/hooks/usePlayerChromeInteractions.ts +++ b/apps/desktop/src/hooks/usePlayerChromeInteractions.ts @@ -81,6 +81,9 @@ export function usePlayerChromeInteractions({ handleResizePointerEnter, handleResizePointerLeave, handleResizePointerDown, + handleResizeSurfacePointerDown, + handleResizeSurfacePointerMove, + handleResizeSurfacePointerEnd, handleResizePointerMove, handleResizePointerEnd, clearResizeHoverFeedback, @@ -91,7 +94,7 @@ export function usePlayerChromeInteractions({ onUserActivity: recordUserActivity, }); clearResizeHoverFeedbackRef.current = clearResizeHoverFeedback; - const shellHandlers = useAppShellHandlers({ + const baseShellHandlers = useAppShellHandlers({ contextMenu, activePluginView, isSettingsOpen, @@ -106,6 +109,13 @@ export function usePlayerChromeInteractions({ handleShellPointerLeave, setVolume, }); + const shellHandlers = { + ...baseShellHandlers, + onPointerDownCapture: handleResizeSurfacePointerDown, + onPointerMoveCapture: handleResizeSurfacePointerMove, + onPointerUpCapture: handleResizeSurfacePointerEnd, + onPointerCancelCapture: handleResizeSurfacePointerEnd, + }; return { isChromePinned, @@ -120,6 +130,9 @@ export function usePlayerChromeInteractions({ handleResizePointerEnter, handleResizePointerLeave, handleResizePointerDown, + handleResizeSurfacePointerDown, + handleResizeSurfacePointerMove, + handleResizeSurfacePointerEnd, handleResizePointerMove, handleResizePointerEnd, clearWindowFrameInteraction, diff --git a/apps/desktop/src/hooks/useWindowFrameInteractions.ts b/apps/desktop/src/hooks/useWindowFrameInteractions.ts index 3582f4a..b6aa422 100644 --- a/apps/desktop/src/hooks/useWindowFrameInteractions.ts +++ b/apps/desktop/src/hooks/useWindowFrameInteractions.ts @@ -26,6 +26,9 @@ export function useWindowFrameInteractions({ handleResizePointerEnter, handleResizePointerLeave, handleResizePointerDown, + handleResizeSurfacePointerDown, + handleResizeSurfacePointerMove, + handleResizeSurfacePointerEnd, handleResizePointerMove, handleResizePointerEnd, clearResizeHoverFeedback, @@ -54,6 +57,9 @@ export function useWindowFrameInteractions({ handleResizePointerEnter, handleResizePointerLeave, handleResizePointerDown, + handleResizeSurfacePointerDown, + handleResizeSurfacePointerMove, + handleResizeSurfacePointerEnd, handleResizePointerMove, handleResizePointerEnd, clearResizeHoverFeedback, diff --git a/apps/desktop/src/hooks/windowFrameInteractions/resize.ts b/apps/desktop/src/hooks/windowFrameInteractions/resize.ts index c784d01..3eacdac 100644 --- a/apps/desktop/src/hooks/windowFrameInteractions/resize.ts +++ b/apps/desktop/src/hooks/windowFrameInteractions/resize.ts @@ -1,4 +1,6 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { WINDOW_RESIZE_EDGE_HIT_PX } from "../../app/constants"; +import { isPointerInsideSelector } from "../../app/shortcuts"; import type { ManualResizeDrag, ResizeDirection, ResizeFeedback } from "../../app/types"; import { applyManualMainWindowResize, @@ -15,6 +17,67 @@ function isMacosResizeRuntime(platformOs: string | null | undefined) { return platformOs === "macos" || (!platformOs && typeof navigator !== "undefined" && /Mac/.test(navigator.platform)); } +function resizeDirectionFromSurfacePointer(event: ReactPointerEvent): ResizeDirection | null { + if ( + !isPointerInsideSelector(event.target, ".resize-region") && + isPointerInsideSelector( + event.target, + [ + "button", + "input", + "select", + "textarea", + "[contenteditable='true']", + ".context-menu", + ".drop-overlay", + ".media-panel", + ".playback-error", + ".playlist-drawer", + ".settings-dialog", + ".transport", + ".window-controls", + ].join(", "), + ) + ) { + return null; + } + + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const nearNorth = y <= WINDOW_RESIZE_EDGE_HIT_PX; + const nearSouth = rect.height - y <= WINDOW_RESIZE_EDGE_HIT_PX; + const nearWest = x <= WINDOW_RESIZE_EDGE_HIT_PX; + const nearEast = rect.width - x <= WINDOW_RESIZE_EDGE_HIT_PX; + + if (nearNorth && nearEast) { + return "NorthEast"; + } + if (nearNorth && nearWest) { + return "NorthWest"; + } + if (nearSouth && nearEast) { + return "SouthEast"; + } + if (nearSouth && nearWest) { + return "SouthWest"; + } + if (nearNorth) { + return "North"; + } + if (nearSouth) { + return "South"; + } + if (nearEast) { + return "East"; + } + if (nearWest) { + return "West"; + } + + return null; +} + export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindowResizeRegionsOptions) { const [resizeFeedback, setResizeFeedback] = useState(null); const manualResizeDragRef = useRef(null); @@ -112,7 +175,7 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow }); } - function startMainWindowResize(event: ReactPointerEvent, direction: ResizeDirection) { + function startMainWindowResize(event: ReactPointerEvent, direction: ResizeDirection) { setNativeResizeCursor(direction); setResizeBoundaryFeedback(direction, true); if (isMacosResizeRuntime(platformOs)) { @@ -161,7 +224,40 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow startMainWindowResize(event, direction); } - function handleResizePointerMove(event: ReactPointerEvent, direction: ResizeDirection) { + function handleResizeSurfacePointerDown(event: ReactPointerEvent) { + if (event.button !== 0) { + return; + } + + const direction = resizeDirectionFromSurfacePointer(event); + if (!direction) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onUserActivity(); + startMainWindowResize(event, direction); + } + + function handleResizeSurfacePointerMove(event: ReactPointerEvent) { + const pendingResize = manualResizeDragRef.current; + if (pendingResize) { + handleResizePointerMove(event, pendingResize.direction); + return; + } + + const direction = resizeDirectionFromSurfacePointer(event); + if (!direction) { + setNativeResizeCursor(null); + setResizeBoundaryFeedback(null); + return; + } + + handleResizePointerMove(event, direction); + } + + function handleResizePointerMove(event: ReactPointerEvent, direction: ResizeDirection) { const pendingResize = manualResizeDragRef.current; if (!pendingResize || pendingResize.pointerId !== event.pointerId) { event.stopPropagation(); @@ -186,7 +282,7 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow requestManualResizeFlush(); } - function handleResizePointerEnd(event: ReactPointerEvent) { + function handleResizePointerEnd(event: ReactPointerEvent) { const pendingResize = manualResizeDragRef.current; if (pendingResize?.pointerId !== event.pointerId) { return; @@ -204,6 +300,10 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow setResizeBoundaryFeedback(null); } + function handleResizeSurfacePointerEnd(event: ReactPointerEvent) { + handleResizePointerEnd(event); + } + function clearResizeHoverFeedback() { if (!manualResizeDragRef.current) { setNativeResizeCursor(null); @@ -222,6 +322,9 @@ export function useWindowResizeRegions({ platformOs, onUserActivity }: UseWindow handleResizePointerEnter, handleResizePointerLeave, handleResizePointerDown, + handleResizeSurfacePointerDown, + handleResizeSurfacePointerMove, + handleResizeSurfacePointerEnd, handleResizePointerMove, handleResizePointerEnd, clearResizeHoverFeedback, From b96954c47a03ec28c644aba1e9c45505a5cd4399 Mon Sep 17 00:00:00 2001 From: AreChen Date: Thu, 25 Jun 2026 18:38:49 +0800 Subject: [PATCH 4/5] fix: sync overlay during manual macos resize --- apps/desktop/scripts/verify-shell.mjs | 3 ++- apps/desktop/src-tauri/src/window/overlay.rs | 4 ++++ apps/desktop/src-tauri/src/window/resize.rs | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/verify-shell.mjs b/apps/desktop/scripts/verify-shell.mjs index c16e93b..029e45a 100644 --- a/apps/desktop/scripts/verify-shell.mjs +++ b/apps/desktop/scripts/verify-shell.mjs @@ -1096,7 +1096,8 @@ assert.match(mpvEmbedRuntimeSource, /mpv_embed_recording_state[\s\S]*mpv_embed_s assert.match(tauriRuntimeSource, /window_start_resize[\s\S]*start_resize_dragging/, "desktop backend must start resizing the main video window from overlay hit areas"); assert.match(tauriRuntimeSource, /fn window_set_resize_cursor[\s\S]*CursorIcon::NeResize[\s\S]*CursorIcon::Default/, "desktop backend must expose native resize cursor icons for overlay hit areas"); assert.match(windowApplyResizeDeltaSource, /set_position[\s\S]*set_size/, "macOS manual resize fallback must resize the main video window"); -assert.doesNotMatch(windowApplyResizeDeltaSource, /sync_overlay_to_main|sync_mpv_video_host/, "macOS manual resize deltas must not run immediate duplicate overlay/mpv sync on every pointermove"); +assert.match(windowApplyResizeDeltaSource, /sync_overlay_to_main_after_resize/, "macOS manual resize fallback must resize the front overlay window after resizing the main video window"); +assert.doesNotMatch(windowApplyResizeDeltaSource, /sync_mpv_video_host|schedule_mpv_video_host_sync/, "macOS manual resize deltas must not run immediate duplicate mpv host sync on every pointermove"); assert.match(tauriRuntimeSource, /window_close/, "desktop backend must keep close command"); assert.match(windowStartDragSource, /main_window\(&app\)\?[\s\S]*start_dragging/, "backend must drag the main video window when overlay drag strip is used"); assert.match(tauriRuntimeSource, /fn app_version/, "desktop backend must expose app version metadata for the about page"); diff --git a/apps/desktop/src-tauri/src/window/overlay.rs b/apps/desktop/src-tauri/src/window/overlay.rs index acebfc4..e0eb7ff 100644 --- a/apps/desktop/src-tauri/src/window/overlay.rs +++ b/apps/desktop/src-tauri/src/window/overlay.rs @@ -25,6 +25,10 @@ fn sync_overlay_to_main_without_focus(app: &AppHandle) { sync_overlay_to_main_with_focus(app, false); } +pub(super) fn sync_overlay_to_main_after_resize(app: &AppHandle) { + sync_overlay_to_main_without_focus(app); +} + fn sync_overlay_to_main_with_focus(app: &AppHandle, focus_overlay: bool) { let Ok(main) = main_window(app) else { return; diff --git a/apps/desktop/src-tauri/src/window/resize.rs b/apps/desktop/src-tauri/src/window/resize.rs index 3c0a7f8..635fb08 100644 --- a/apps/desktop/src-tauri/src/window/resize.rs +++ b/apps/desktop/src-tauri/src/window/resize.rs @@ -1,7 +1,7 @@ use tauri::{AppHandle, CursorIcon, PhysicalPosition, PhysicalSize, Position, Size}; use tauri_runtime::ResizeDirection; -use super::{MIN_MAIN_WINDOW_HEIGHT, MIN_MAIN_WINDOW_WIDTH, main_window, overlay_window}; +use super::{MIN_MAIN_WINDOW_HEIGHT, MIN_MAIN_WINDOW_WIDTH, main_window, overlay, overlay_window}; pub(crate) fn window_start_resize(app: AppHandle, direction: String) -> Result<(), String> { let direction = resize_direction_from_str(&direction)?; @@ -120,6 +120,7 @@ pub(crate) fn window_apply_resize_delta( height: height as u32, })) .map_err(|error| error.to_string())?; + overlay::sync_overlay_to_main_after_resize(&app); Ok(()) } From c300136d62f9d13fa8d613337e2899938866385b Mon Sep 17 00:00:00 2001 From: AreChen Date: Thu, 25 Jun 2026 19:25:53 +0800 Subject: [PATCH 5/5] fix: use native macos overlay resizing --- apps/desktop/scripts/verify-shell.mjs | 3 ++ apps/desktop/src-tauri/src/window/overlay.rs | 44 +++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/desktop/scripts/verify-shell.mjs b/apps/desktop/scripts/verify-shell.mjs index 029e45a..7e1147e 100644 --- a/apps/desktop/scripts/verify-shell.mjs +++ b/apps/desktop/scripts/verify-shell.mjs @@ -1079,6 +1079,9 @@ assert.match(tauriRuntimeSource, /VK_OEM_5/, "Windows shortcut bridge must recog assert.match(tauriRuntimeSource, /openplayer-native-shortcut/, "native shortcut bridge must emit actions to the overlay frontend"); assert.match(tauriRuntimeSource, /sync_overlay_to_main[\s\S]*focus_overlay_window\(app\)/, "overlay sync must return keyboard focus to the controls window"); assert.match(tauriRuntimeSource, /openplayer_macos_prepare_overlay_window/, "macOS overlay window must be marked as a fullscreen auxiliary child of the video window"); +assert.match(tauriRuntimeSource, /WebviewWindowBuilder::new\([\s\S]*"overlay"[\s\S]*\.resizable\(cfg!\(target_os = "macos"\)\)/, "macOS overlay window must be natively resizable because it is the front window receiving edge hit-tests"); +assert.match(tauriRuntimeSource, /fn sync_main_to_overlay_after_resize[\s\S]*main\.set_position[\s\S]*main\.set_size/, "macOS native overlay resize must resize the main video window to match the front controls window"); +assert.match(tauriRuntimeSource, /overlay\.on_window_event\([\s\S]*WindowEvent::(?:Moved|Resized)[\s\S]*sync_main_to_overlay_after_resize/, "overlay native resize and move events must synchronize the main video window"); assert.match(macosGlViewSource, /NSWindowCollectionBehaviorFullScreenAuxiliary[\s\S]*addChildWindow/, "macOS overlay must join the main video fullscreen space instead of creating a separate fullscreen space"); assert.match(tauriRuntimeSource, /WindowEvent::Focused\(true\)[\s\S]*focus_overlay_window\(&app_handle\)/, "clicking the video/main window must return keyboard focus to the overlay shortcut handler"); assert.match(tauriRuntimeSource, /WindowEvent::CloseRequested[\s\S]*overlay_window\(&app_handle\)[\s\S]*overlay\.close\(\)/, "closing the main video window through OS chrome or Alt+F4 must also close the overlay controls window"); diff --git a/apps/desktop/src-tauri/src/window/overlay.rs b/apps/desktop/src-tauri/src/window/overlay.rs index e0eb7ff..802d7d4 100644 --- a/apps/desktop/src-tauri/src/window/overlay.rs +++ b/apps/desktop/src-tauri/src/window/overlay.rs @@ -9,7 +9,7 @@ use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, Position, Size}; use tauri::{WebviewUrl, WebviewWindowBuilder, WindowEvent, utils::config::Color}; use super::{ - WindowState, begin_window_close, main_window, + MIN_MAIN_WINDOW_HEIGHT, MIN_MAIN_WINDOW_WIDTH, WindowState, begin_window_close, main_window, overlay_platform::prepare_macos_main_window_chrome, overlay_platform::set_overlay_owner, overlay_window, }; @@ -29,6 +29,34 @@ pub(super) fn sync_overlay_to_main_after_resize(app: &AppHandle) { sync_overlay_to_main_without_focus(app); } +fn sync_main_to_overlay_after_resize(app: &AppHandle) { + let Ok(main) = main_window(app) else { + return; + }; + if main.is_fullscreen().unwrap_or(false) || main.is_maximized().unwrap_or(false) { + return; + } + let Some(overlay) = overlay_window(app) else { + return; + }; + let Ok(position) = overlay.outer_position() else { + return; + }; + let Ok(size) = overlay.outer_size() else { + return; + }; + let _ = main.set_position(Position::Physical(PhysicalPosition { + x: position.x, + y: position.y, + })); + let _ = main.set_size(Size::Physical(PhysicalSize { + width: size.width, + height: size.height, + })); + #[cfg(feature = "mpv-embed")] + schedule_mpv_video_host_sync(app); +} + fn sync_overlay_to_main_with_focus(app: &AppHandle, focus_overlay: bool) { let Ok(main) = main_window(app) else { return; @@ -116,7 +144,8 @@ pub(crate) fn setup_overlay_window(app: &mut tauri::App) -> Result<(), String> { .decorations(false) .transparent(true) .shadow(false) - .resizable(false) + .resizable(cfg!(target_os = "macos")) + .min_inner_size(MIN_MAIN_WINDOW_WIDTH as f64, MIN_MAIN_WINDOW_HEIGHT as f64) .skip_taskbar(true) .background_color(Color(0, 0, 0, 0)) .visible(false) @@ -129,20 +158,23 @@ pub(crate) fn setup_overlay_window(app: &mut tauri::App) -> Result<(), String> { sync_overlay_to_main(&app_handle); let _ = overlay.show(); set_overlay_owner(&window, &overlay); - let app_handle_for_overlay_close = app_handle.clone(); + let app_handle_for_overlay = app_handle.clone(); overlay.on_window_event(move |event| { if matches!(event, WindowEvent::CloseRequested { .. }) { - let window_state = app_handle_for_overlay_close.state::(); + let window_state = app_handle_for_overlay.state::(); if !begin_window_close(window_state.inner()) { return; } - let _ = stop_embedded_player_for_close(&app_handle_for_overlay_close); - let Ok(main) = main_window(&app_handle_for_overlay_close) else { + let _ = stop_embedded_player_for_close(&app_handle_for_overlay); + let Ok(main) = main_window(&app_handle_for_overlay) else { return; }; let _ = main.close(); } + if matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { + sync_main_to_overlay_after_resize(&app_handle_for_overlay); + } }); window.on_window_event(move |event| { if matches!(event, WindowEvent::CloseRequested { .. }) {