From 9e8cfbc7ddc7bad3f626cd12a07f3b07b6960979 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:03:44 +0200 Subject: [PATCH] fix(windows): keep video-child input on the Flutter view Create the mpv host window with WS_DISABLED so Windows skips the video subtree when it picks the window that owns a contact and hands the input to the parent Flutter view instead. Touch over the video never reached Flutter before: mpv's inner window owns the contact from its own thread, and neither relay worked from there - Flutter resolves WM_POINTER with GetPointerInfo, which only answers for a message the calling thread retrieved, and the system discards a cross-thread pointer send outright. WS_EX_TRANSPARENT and an HTTRANSPARENT WM_NCHITTEST reply are both same-thread-only, so disabling the subtree is the one hit-test opt-out that applies across threads. The mouse relay stays for input that still reaches mpv's window. Repair the contract test that covers this. It drove its pointer assertions with cross-thread sends that Windows drops, so every touch assertion had been dead since it was added and the suite fails "primary touch must press once" on main. Relaying those sends through the window's own thread runs all eight tests, and injected mouse and touch presses over the disabled host now assert delivery to the parent view; removing WS_DISABLED fails the suite. close #1556 --- windows/runner/mpv/mpv_player.cpp | 40 ++- windows/runner/mpv/mpv_player.h | 5 + .../mpv/mpv_player_property_contract_test.cpp | 315 +++++++++++++++++- 3 files changed, 329 insertions(+), 31 deletions(-) diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 8fd05e51..4ceacfb1 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -66,14 +66,24 @@ flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { } } -// DComp-mode input forwarding. mpv's inner window lives on mpv's own thread -// and consumes input over the video (WS_EX_TRANSPARENT hit-test skipping is -// same-thread-only, and disabling the subtree makes the system drop the input -// entirely instead of routing it to a sibling). Use the common-controls -// subclass chain with per-window reference data. Mouse messages can be posted -// directly, but WM_POINTER metadata remains associated with the receiving -// window thread, so translate the primary contact to mouse input before -// forwarding it to Flutter. +// Input ownership for the DComp video child. +// +// The video host window is created disabled (WS_DISABLED). A disabled window +// and — implicitly — its children are skipped when the system picks the window +// that owns a contact, and the input is handed to the parent instead, so mouse, +// touch, and pen over the video land on the Flutter view itself, on the +// platform thread, with their real device kind. That is the only hit-test +// opt-out that works across threads: WS_EX_TRANSPARENT and an HTTRANSPARENT +// WM_NCHITTEST reply are both documented as same-thread-only, and mpv's inner +// window lives on mpv's own thread. +// +// The subclass below is the fallback for input that still reaches mpv's window. +// It uses the common-controls subclass chain with per-window reference data. +// Mouse messages can be posted straight through, but a WM_POINTER message +// cannot be forwarded as-is: Flutter resolves it with GetPointerInfo, which +// only answers for a pointer message the calling thread retrieved itself, and +// silently drops the event when that lookup fails. The primary contact is +// therefore translated into mouse input. constexpr bool IsForwardedPointerMessage(UINT message) { switch (message) { case WM_POINTERDOWN: @@ -101,6 +111,7 @@ LPARAM PointerPositionInView(HWND view, LPARAM lparam) { ::ScreenToClient(view, &point); return MAKELPARAM(point.x, point.y); } + void ReleaseForwardedPointer(InnerWindowSubclassState& state, HWND view) { std::lock_guard lock(state.forwarded_pointer_mutex); if (state.forwarded_pointer_token.exchange(0, std::memory_order_acq_rel) == 0 || !view) { @@ -536,12 +547,15 @@ bool MpvPlayer::Initialize(HWND view) { // |view|. The video child then sits in the view's own per-window layer // stack, above the view's (never-painted) layer-1 content and below the // engine's topmost DComp visual carrying the UI. WS_CLIPSIBLINGS keeps it - // from painting over neighboring view children. Mouse, touch, and stylus - // input over the video is delivered to mpv's own inner window (on mpv's - // thread); the subclass installed in EnsureMpvInnerSubclassed forwards it - // back to the Flutter view. + // from painting over neighboring view children. + // + // WS_DISABLED takes the host — and the inner window mpv creates inside it, + // which a disabled parent disables implicitly — out of input targeting, so + // mouse, touch, and pen over the video are delivered to the parent Flutter + // view instead of to mpv's thread. mpv never consumes input here anyway + // (input-vo-keyboard=no, and the forwarding subclass swallows the rest). hwnd_ = ::CreateWindowExW( - WS_EX_NOPARENTNOTIFY, L"STATIC", L"", WS_CHILD | WS_CLIPSIBLINGS, 0, 0, 100, 100, view, nullptr, + WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle, 0, 0, 100, 100, view, nullptr, GetModuleHandle(nullptr), nullptr); if (!hwnd_) { mpv_destroy(mpv_); diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 7c3db9d6..eb16cfe1 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -19,6 +19,11 @@ namespace mpv { struct InnerWindowSubclassState; +// Style of the window mpv renders into. WS_DISABLED takes the host, and every +// window mpv creates inside it, out of input targeting so mouse, touch, and pen +// over the video reach the parent Flutter view instead of mpv's thread. +inline constexpr DWORD kVideoHostWindowStyle = WS_CHILD | WS_CLIPSIBLINGS | WS_DISABLED; + // Wrapper for libmpv that handles initialization, commands, properties, // and event dispatching. class MpvPlayer { diff --git a/windows/runner/mpv/mpv_player_property_contract_test.cpp b/windows/runner/mpv/mpv_player_property_contract_test.cpp index 893c4671..eb37af29 100644 --- a/windows/runner/mpv/mpv_player_property_contract_test.cpp +++ b/windows/runner/mpv/mpv_player_property_contract_test.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -60,6 +61,23 @@ std::atomic g_block_entered{nullptr}; std::atomic g_block_release{nullptr}; std::atomic g_block_exited{nullptr}; +// A WM_POINTER message cannot be handed to a window owned by another thread: +// the system drops a cross-thread send and refuses the post outright with +// ERROR_MESSAGE_SYNC_ONLY, because pointer data belongs to the queue of the +// thread that retrieved it. Driving mpv's window from the test thread therefore +// delivers nothing at all. This relays the send through the window's own thread, +// which is also where the real messages arrive in production. +constexpr UINT kDispatchOnOwnerThreadMessage = WM_APP + 0x0506; +struct OwnerThreadDispatch { + HWND target; + UINT message; + WPARAM wparam; + LPARAM lparam; +}; +std::atomic g_routing_view{nullptr}; +std::atomic g_routing_view_presses{0}; +std::atomic g_routing_child_presses{0}; + LRESULT CALLBACK CountingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { if (message == kBlockWindowThreadMessage) { const HANDLE entered = g_block_entered.load(std::memory_order_acquire); @@ -72,6 +90,10 @@ LRESULT CALLBACK CountingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPAR } return 0; } + if (message == kDispatchOnOwnerThreadMessage) { + const auto* dispatch = reinterpret_cast(lparam); + return ::SendMessageW(dispatch->target, dispatch->message, dispatch->wparam, dispatch->lparam); + } if (message == WM_MOUSEMOVE) { if ((wparam & MK_LBUTTON) != 0) { g_forwarded_touch_moves.fetch_add(1, std::memory_order_relaxed); @@ -99,6 +121,96 @@ LRESULT CALLBACK CountingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPAR return ::DefWindowProcW(hwnd, message, wparam, lparam); } +// Sends |message| to |target| from the thread that owns |owner_window|, so a +// WM_POINTER message reaches the window procedure instead of being discarded as +// a cross-thread send. |owner_window| must be handled by CountingWindowProc and +// live on the same thread as |target|. +LRESULT SendFromOwnerThread(HWND owner_window, HWND target, UINT message, WPARAM wparam, LPARAM lparam) { + OwnerThreadDispatch dispatch{target, message, wparam, lparam}; + return ::SendMessageW(owner_window, kDispatchOnOwnerThreadMessage, 0, reinterpret_cast(&dispatch)); +} + +// Attributes a real button press to the window that received it. Installed on +// the stand-in view and on every window of the video subtree below it. The +// STATIC class answers WM_NCHITTEST with HTTRANSPARENT, which would take these +// windows out of the hit test for a reason other than the one under test, so +// every one of them claims its client area here. +LRESULT CALLBACK RoutingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == WM_NCHITTEST) { + return HTCLIENT; + } + if (message == WM_LBUTTONDOWN || message == WM_LBUTTONDBLCLK) { + if (hwnd == g_routing_view.load(std::memory_order_acquire)) { + g_routing_view_presses.fetch_add(1, std::memory_order_relaxed); + } else { + g_routing_child_presses.fetch_add(1, std::memory_order_relaxed); + } + return 0; + } + return ::DefWindowProcW(hwnd, message, wparam, lparam); +} + +// Pumps this thread's queue until |predicate| holds or the budget runs out. +bool PumpUntil(const std::function& predicate, DWORD timeout_ms) { + const ULONGLONG deadline = ::GetTickCount64() + timeout_ms; + for (;;) { + MSG message; + while (::PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + ::TranslateMessage(&message); + ::DispatchMessageW(&message); + } + if (predicate()) return true; + if (::GetTickCount64() >= deadline) return false; + ::Sleep(5); + } +} + +bool SendAbsoluteMouse(POINT screen_point, DWORD flags) { + const LONG width = ::GetSystemMetrics(SM_CXSCREEN); + const LONG height = ::GetSystemMetrics(SM_CYSCREEN); + if (width < 2 || height < 2) return false; + INPUT input = {}; + input.type = INPUT_MOUSE; + input.mi.dx = static_cast((static_cast(screen_point.x) * 65535) / (width - 1)); + input.mi.dy = static_cast((static_cast(screen_point.y) * 65535) / (height - 1)); + input.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | flags; + return ::SendInput(1, &input, sizeof(input)) == 1; +} + +bool InjectClick(POINT screen_point) { + return SendAbsoluteMouse(screen_point, MOUSEEVENTF_MOVE) && SendAbsoluteMouse(screen_point, MOUSEEVENTF_LEFTDOWN) && + SendAbsoluteMouse(screen_point, MOUSEEVENTF_LEFTUP); +} + +// A real touch contact at |screen_point|, for the touch half of the routing +// test. The contact is held across a few frames rather than sent as a bare +// down/up pair, because a contact that lifts in the same frame it landed is +// short enough for a consumer to discard as noise. +bool InjectTap(POINT screen_point) { + POINTER_TOUCH_INFO contact = {}; + contact.pointerInfo.pointerType = PT_TOUCH; + contact.pointerInfo.pointerId = 0; + contact.pointerInfo.ptPixelLocation = screen_point; + contact.touchFlags = TOUCH_FLAG_NONE; + contact.touchMask = TOUCH_MASK_CONTACTAREA | TOUCH_MASK_ORIENTATION | TOUCH_MASK_PRESSURE; + contact.rcContact.left = screen_point.x - 2; + contact.rcContact.top = screen_point.y - 2; + contact.rcContact.right = screen_point.x + 2; + contact.rcContact.bottom = screen_point.y + 2; + contact.orientation = 90; + contact.pressure = 32000; + + contact.pointerInfo.pointerFlags = POINTER_FLAG_DOWN | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT; + if (!::InjectTouchInput(1, &contact)) return false; + contact.pointerInfo.pointerFlags = POINTER_FLAG_UPDATE | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT; + for (int frame = 0; frame < 5; ++frame) { + ::Sleep(20); + if (!::InjectTouchInput(1, &contact)) return false; + } + contact.pointerInfo.pointerFlags = POINTER_FLAG_UP; + return ::InjectTouchInput(1, &contact) != FALSE; +} + void TestUnavailablePropertyWriteFails() { MpvPlayer player; int callback_count = 0; @@ -225,14 +337,14 @@ void TestInnerSubclassOwnershipIsSerializedAndDetached() { POINT expected_up_position = up_position; ::ScreenToClient(windows.target, &expected_up_position); - ::SendMessageW( - windows.inner, WM_POINTERDOWN, MAKEWPARAM(kPrimaryPointerId, kPointerDownFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kPrimaryPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); - ::SendMessageW( - windows.inner, WM_POINTERUPDATE, MAKEWPARAM(kPrimaryPointerId, kPointerMoveFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERUPDATE, MAKEWPARAM(kPrimaryPointerId, kPointerMoveFlags), MAKELPARAM(up_position.x, up_position.y)); - ::SendMessageW( - windows.inner, WM_POINTERUP, MAKEWPARAM(kPrimaryPointerId, kPointerUpFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kPrimaryPointerId, kPointerUpFlags), MAKELPARAM(up_position.x, up_position.y)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 1; ++attempt) { ::Sleep(10); @@ -260,19 +372,21 @@ void TestInnerSubclassOwnershipIsSerializedAndDetached() { const WPARAM secondary_down = MAKEWPARAM(kSecondaryPointerId, kPointerDownFlags & ~POINTER_MESSAGE_FLAG_PRIMARY); const WPARAM secondary_up = MAKEWPARAM(kSecondaryPointerId, kPointerUpFlags & ~POINTER_MESSAGE_FLAG_PRIMARY); - ::SendMessageW(windows.inner, WM_POINTERDOWN, secondary_down, MAKELPARAM(down_position.x, down_position.y)); - ::SendMessageW(windows.inner, WM_POINTERUP, secondary_up, MAKELPARAM(up_position.x, up_position.y)); + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERDOWN, secondary_down, MAKELPARAM(down_position.x, down_position.y)); + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERUP, secondary_up, MAKELPARAM(up_position.x, up_position.y)); ::Sleep(30); Check( g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 1 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 1, "secondary touch must not synthesize another click"); - ::SendMessageW( - windows.inner, WM_POINTERDOWN, MAKEWPARAM(kCancelledPointerId, kPointerDownFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kCancelledPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); - ::SendMessageW( - windows.inner, WM_POINTERCAPTURECHANGED, MAKEWPARAM(kCancelledPointerId, kPointerUpFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERCAPTURECHANGED, MAKEWPARAM(kCancelledPointerId, kPointerUpFlags), reinterpret_cast(windows.host)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 2; ++attempt) { ::Sleep(10); @@ -282,8 +396,8 @@ void TestInnerSubclassOwnershipIsSerializedAndDetached() { g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 2, "capture loss must release an active synthetic mouse press"); - ::SendMessageW( - windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDetachedPointerId, kPointerDownFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDetachedPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) < 3; ++attempt) { @@ -302,8 +416,8 @@ void TestInnerSubclassOwnershipIsSerializedAndDetached() { g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3, "detach must release an active synthetic mouse press"); - ::SendMessageW( - windows.inner, WM_POINTERUP, MAKEWPARAM(kDetachedPointerId, kPointerUpFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kDetachedPointerId, kPointerUpFlags), MAKELPARAM(up_position.x, up_position.y)); ::Sleep(30); Check( @@ -339,8 +453,8 @@ void TestInnerSubclassOwnershipIsSerializedAndDetached() { destroy_generation && destroy_generation != windows.inner_original, "destroy test must install a fresh subclass generation"); - ::SendMessageW( - windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDestroyedPointerId, kPointerDownFlags), + SendFromOwnerThread( + windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDestroyedPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); ::SendMessageW(windows.inner, WM_CLOSE, 0, 0); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 4; ++attempt) { @@ -522,6 +636,169 @@ void TestTimedOutSubclassInstallCannotOutliveItsState() { window_owner.join(); } +// The video child must never own a contact: mpv's window lives on mpv's own +// thread, where the cross-thread hit-test opt-outs (WS_EX_TRANSPARENT, an +// HTTRANSPARENT WM_NCHITTEST reply) do not apply. Disabling the host is what +// keeps mouse, touch, and pen on the Flutter view. The window mpv creates +// inside the host afterwards needs no state of its own — targeting skips the +// host without descending into it — so only the host's own state is asserted +// here (a disabled parent never clears its children's WS_DISABLED bit). +void TestDisabledVideoHostKeepsInputOnTheFlutterView() { + HWND view = ::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 400, 400, nullptr, nullptr, nullptr, nullptr); + Check(view != nullptr, "video host test needs a stand-in Flutter view window"); + RECT client = {}; + Check(::GetClientRect(view, &client) != FALSE, "the stand-in view must report a client area"); + Check(client.right > 2 && client.bottom > 2, "the stand-in view must have a usable client area"); + + HWND host = ::CreateWindowExW( + WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle, 0, 0, client.right, client.bottom, view, nullptr, + nullptr, nullptr); + Check(host != nullptr, "the video host window must be created"); + Check(!::IsWindowEnabled(host), "the video host must be created disabled"); + + const POINT contact = {client.right / 2, client.bottom / 2}; + Check( + ::ChildWindowFromPointEx(view, contact, CWP_SKIPDISABLED) == view, + "input over the disabled video host must target the Flutter view"); + + ::EnableWindow(host, TRUE); + Check( + ::ChildWindowFromPointEx(view, contact, CWP_SKIPDISABLED) == host, + "the same contact must reach an enabled host, so the skip is the disabled state and not the geometry"); + + ::DestroyWindow(host); + ::DestroyWindow(view); +} + +// The assertions above only prove that USER32's own child lookup honors +// CWP_SKIPDISABLED. What the video child actually depends on is the input hit +// test: a press over the disabled host has to reach the parent view, and must +// not be swallowed — swallowing is what the previous implementation reported +// when it tried disabling this subtree, and no relay can recover from it +// because mpv's window never sees the message either. +// +// A control press with the video subtree hidden runs first. If that one does +// not arrive, injection is unavailable here and the test skips. Once it has +// landed, silence from the press over the disabled host is a failure, not an +// environment problem. +void TestDisabledVideoHostRoutesRealPressesToParentView() { + HWND view = ::CreateWindowExW( + 0, L"STATIC", L"", WS_OVERLAPPEDWINDOW | WS_VISIBLE, 100, 100, 400, 400, nullptr, nullptr, nullptr, nullptr); + Check(view != nullptr, "input routing test needs a stand-in Flutter view window"); + RECT client = {}; + Check(::GetClientRect(view, &client) != FALSE, "the stand-in view must report a client area"); + Check(client.right > 2 && client.bottom > 2, "the stand-in view must have a usable client area"); + + HWND host = ::CreateWindowExW( + WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle | WS_VISIBLE, 0, 0, client.right, client.bottom, view, + nullptr, nullptr, nullptr); + Check(host != nullptr, "the video host window must be created"); + HWND inner = ::CreateWindowExW( + 0, L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, client.right, client.bottom, host, nullptr, nullptr, nullptr); + Check(inner != nullptr, "the mpv inner window stand-in must be created"); + + g_routing_view.store(view, std::memory_order_release); + g_routing_view_presses.store(0, std::memory_order_relaxed); + g_routing_child_presses.store(0, std::memory_order_relaxed); + const auto view_original = + reinterpret_cast(::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast(RoutingWindowProc))); + const auto host_original = + reinterpret_cast(::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast(RoutingWindowProc))); + const auto inner_original = reinterpret_cast( + ::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast(RoutingWindowProc))); + + ::SetForegroundWindow(view); + PumpUntil([]() { return false; }, 100); + + POINT contact = {client.right / 2, client.bottom / 2}; + ::ClientToScreen(view, &contact); + POINT restore_cursor = {}; + const bool cursor_known = ::GetCursorPos(&restore_cursor) != FALSE; + + // Control: with the video subtree hidden, this press can only land on the + // view. It establishes that injection reaches this process at all. + ::ShowWindow(host, SW_HIDE); + PumpUntil([]() { return false; }, 50); + bool control_landed = false; + if (::WindowFromPoint(contact) == view && InjectClick(contact)) { + control_landed = PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); + } + + if (!control_landed) { + std::cout << "mpv_player_property_contract_test: skipped injected input routing (no usable desktop)\n"; + } else { + g_routing_view_presses.store(0, std::memory_order_relaxed); + g_routing_child_presses.store(0, std::memory_order_relaxed); + ::ShowWindow(host, SW_SHOWNA); + PumpUntil([]() { return false; }, 50); + + Check( + ::WindowFromPoint(contact) == view, + "the input hit test must skip the disabled video subtree and resolve to the Flutter view"); + Check(InjectClick(contact), "the control press injected, so the press over the video host must inject too"); + PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); + + Check( + g_routing_child_presses.load(std::memory_order_relaxed) == 0, + "a disabled video subtree must not receive the press itself"); + Check( + g_routing_view_presses.load(std::memory_order_relaxed) == 1, + "a press over the disabled video host must reach the parent Flutter view instead of being swallowed"); + + // Touch is the contact kind issue #1556 was reported for, and it takes a + // different path into the system than the mouse press above: the injection + // below produces a real touch contact, so the system's own hit test picks + // the target and promotes it to the legacy mouse stream the counters watch. + // A machine without a digitizer can still inject, so this is not gated on + // SM_DIGITIZER - only on the injection API being usable at all. + if (!::InitializeTouchInjection(1, TOUCH_FEEDBACK_NONE)) { + std::cout << "mpv_player_property_contract_test: skipped injected touch routing (injection unavailable)\n"; + } else { + g_routing_view_presses.store(0, std::memory_order_relaxed); + g_routing_child_presses.store(0, std::memory_order_relaxed); + ::ShowWindow(host, SW_HIDE); + PumpUntil([]() { return false; }, 50); + + // Same control as the mouse pass: with the video subtree hidden the tap + // can only land on the view, which proves touch injection works here + // before silence over the disabled host is treated as a failure. + bool touch_control_landed = false; + if (InjectTap(contact)) { + touch_control_landed = + PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); + } + + if (!touch_control_landed) { + std::cout << "mpv_player_property_contract_test: skipped injected touch routing (no usable desktop)\n"; + } else { + g_routing_view_presses.store(0, std::memory_order_relaxed); + g_routing_child_presses.store(0, std::memory_order_relaxed); + ::ShowWindow(host, SW_SHOWNA); + PumpUntil([]() { return false; }, 50); + + Check(InjectTap(contact), "the control tap injected, so the tap over the video host must inject too"); + PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); + + Check( + g_routing_child_presses.load(std::memory_order_relaxed) == 0, + "a disabled video subtree must not receive the touch itself"); + Check( + g_routing_view_presses.load(std::memory_order_relaxed) == 1, + "a touch over the disabled video host must reach the parent Flutter view instead of being swallowed"); + } + } + } + + if (cursor_known) ::SetCursorPos(restore_cursor.x, restore_cursor.y); + ::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast(inner_original)); + ::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast(host_original)); + ::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast(view_original)); + g_routing_view.store(nullptr, std::memory_order_release); + ::DestroyWindow(inner); + ::DestroyWindow(host); + ::DestroyWindow(view); +} + } // namespace } // namespace mpv @@ -532,6 +809,8 @@ int main() { mpv::TestInnerSubclassOwnershipIsSerializedAndDetached(); mpv::TestTimedOutSubclassDetachCanBeAdopted(); mpv::TestTimedOutSubclassInstallCannotOutliveItsState(); + mpv::TestDisabledVideoHostKeepsInputOnTheFlutterView(); + mpv::TestDisabledVideoHostRoutesRealPressesToParentView(); std::cout << "mpv_player_property_contract_test: PASS\n"; return 0; }