Files
plezy/windows/runner/mpv/mpv_player_property_contract_test.cpp
T
edde746 baa31742cb fix(player): bind live TV clock generations to the source the load reports
Live-TV clock generations were matched to mpv sources first-in-first-out: every
start-file popped the oldest registered open, on the assumption of exactly one
start-file per open in dispatch order. Opens without a generation on the same
player, an Android loadfile rejected silently by nativeCommand, and the
independent delivery of the command ack and the start-file event all broke
that, so a seek that reopened the stream could calibrate against the wrong
source.

The loadfile reply now carries mpv's playlist_entry_id on Android, Apple, Linux
and Windows, PlayerNative.open resolves with it, and the live session binds
each generation to that id explicitly. Source events that land before the
reply are buffered per id and replayed on binding; a rejected or unreachable
load fails its generation instead of leaving a phantom; opens with no
generation are invisible to clock binding. Android now reports a rejected mpv
command as COMMAND_FAILED like the other cores.
2026-09-06 13:29:37 +02:00

1069 lines
49 KiB
C++

#include <windowsx.h>
#include <atomic>
#include <cstdlib>
#include <functional>
#include <future>
#include <iostream>
#include <limits>
#include <thread>
#include <utility>
#include <vector>
#include "mpv_player.h"
namespace mpv {
class MpvPlayerPropertyContractTestPeer {
public:
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
player.pending_requests_.RegisterStatus(std::move(callback));
}
static void RegisterPendingPropertyRead(MpvPlayer& player, MpvPlayer::GetPropertyCallback callback) {
player.pending_requests_.RegisterProperty(std::move(callback));
}
static uint64_t RegisterPendingCommand(MpvPlayer& player, MpvPlayer::CommandCallback callback) {
return player.pending_requests_.RegisterCommand(std::move(callback));
}
static void RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) {
player.observed_properties_.Register(name, "node", id);
}
static void HandleEvent(MpvPlayer& player, mpv_event* event) { player.HandleMpvEvent(event); }
static void SendPlaybackRestart(MpvPlayer& player, const double* position_seconds) {
player.SendPlaybackRestartEvent(position_seconds);
}
static void ConfigureInnerSubclass(MpvPlayer& player, HWND host, HWND target) {
player.hwnd_ = host;
player.forward_target_view_ = target;
}
static void EnsureInnerSubclass(MpvPlayer& player) { player.EnsureMpvInnerSubclassed(); }
static void DetachInnerSubclass(MpvPlayer& player) { player.DetachMpvInnerSubclass(); }
static const void* InnerSubclassIdentity(const MpvPlayer& player) { return player.inner_subclass_.get(); }
static void ReleaseTestWindows(MpvPlayer& player) {
player.DetachMpvInnerSubclass();
player.hwnd_ = nullptr;
player.forward_target_view_ = nullptr;
}
};
namespace {
void Check(bool condition, const char* message) {
if (!condition) {
std::cerr << "mpv_player_property_contract_test: " << message << '\n';
std::exit(1);
}
}
const flutter::EncodableMap& RequireMap(const flutter::EncodableValue& value, const char* message) {
Check(std::holds_alternative<flutter::EncodableMap>(value), message);
return std::get<flutter::EncodableMap>(value);
}
const flutter::EncodableValue& RequireMapField(const flutter::EncodableMap& map, const char* key, const char* message) {
auto value = map.find(flutter::EncodableValue(key));
Check(value != map.end(), message);
return value->second;
}
const flutter::EncodableMap& RequireEventData(const flutter::EncodableValue& event, const char* expected_name) {
const auto& envelope = RequireMap(event, "lifecycle event must be a map");
const auto& name = RequireMapField(envelope, "name", "lifecycle event name is missing");
Check(
std::holds_alternative<std::string>(name) && std::get<std::string>(name) == expected_name,
"lifecycle event name changed");
return RequireMap(
RequireMapField(envelope, "data", "source-qualified lifecycle event data is missing"),
"lifecycle event data must be a map");
}
int64_t RequireSourceId(const flutter::EncodableMap& data, const char* message) {
const auto& source_id = RequireMapField(data, "sourceId", message);
Check(std::holds_alternative<int64_t>(source_id), "source ID must use the signed 64-bit codec type");
return std::get<int64_t>(source_id);
}
void TestSourceQualifiedEventPayloads() {
MpvPlayer player;
MpvPlayerPropertyContractTestPeer::RegisterObservedNode(player, "track-list", 42);
std::vector<flutter::EncodableValue> events;
player.SetEventCallback([&events](const flutter::EncodableValue& event) {
// Diagnostic logs do not participate in the playback-source lifecycle.
if (const auto* envelope = std::get_if<flutter::EncodableMap>(&event)) {
const auto& name = RequireMapField(*envelope, "name", "event name is missing");
if (const auto* text = std::get_if<std::string>(&name); text && *text == "log-message") return;
}
events.push_back(event);
});
mpv_event_property property{};
property.name = "track-list";
property.format = MPV_FORMAT_NODE;
mpv_event property_event{};
property_event.event_id = MPV_EVENT_PROPERTY_CHANGE;
property_event.data = &property;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &property_event);
constexpr int64_t kFirstSourceId = -5000000001LL;
mpv_event_start_file start{};
start.playlist_entry_id = kFirstSourceId;
mpv_event start_event{};
start_event.event_id = MPV_EVENT_START_FILE;
start_event.data = &start;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &start_event);
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &property_event);
mpv_event file_loaded{};
file_loaded.event_id = MPV_EVENT_FILE_LOADED;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &file_loaded);
mpv_event playback_restart{};
playback_restart.event_id = MPV_EVENT_PLAYBACK_RESTART;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &playback_restart);
const double position_seconds = 17.25;
MpvPlayerPropertyContractTestPeer::SendPlaybackRestart(player, &position_seconds);
const double invalid_position = std::numeric_limits<double>::infinity();
MpvPlayerPropertyContractTestPeer::SendPlaybackRestart(player, &invalid_position);
constexpr int64_t kEndedSourceId = 6000000002LL;
mpv_event_end_file end{};
end.reason = MPV_END_FILE_REASON_ERROR;
end.error = MPV_ERROR_LOADING_FAILED;
end.playlist_entry_id = kEndedSourceId;
mpv_event end_event{};
end_event.event_id = MPV_EVENT_END_FILE;
end_event.data = &end;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &end_event);
constexpr int64_t kNextSourceId = 7000000003LL;
start.playlist_entry_id = kNextSourceId;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &start_event);
const auto& property_before_start = std::get<flutter::EncodableList>(events.at(0));
Check(property_before_start.size() == 3, "property event must contain ID, value, and source ID");
Check(
std::holds_alternative<int32_t>(property_before_start[0]) && std::get<int32_t>(property_before_start[0]) == 42,
"property event ID changed");
Check(std::holds_alternative<std::monostate>(property_before_start[1]), "missing property data must remain null");
Check(
std::holds_alternative<std::monostate>(property_before_start[2]),
"property source must be null before START_FILE");
const auto& start_data = RequireEventData(events.at(1), "start-file");
Check(
RequireSourceId(start_data, "start-file source ID is missing") == kFirstSourceId,
"start-file source ID lost signed 64-bit precision");
const auto& source_property = std::get<flutter::EncodableList>(events.at(2));
Check(source_property.size() == 3, "source-qualified property event must remain a triple");
Check(
std::holds_alternative<int64_t>(source_property[2]) && std::get<int64_t>(source_property[2]) == kFirstSourceId,
"property event did not retain the active source ID");
const auto& loaded_data = RequireEventData(events.at(3), "file-loaded");
Check(
RequireSourceId(loaded_data, "file-loaded source ID is missing") == kFirstSourceId,
"file-loaded source ID changed");
const auto& restart_without_position = RequireEventData(events.at(4), "playback-restart");
Check(
RequireSourceId(restart_without_position, "playback-restart source ID is missing") == kFirstSourceId,
"playback-restart source ID changed");
Check(
restart_without_position.find(flutter::EncodableValue("positionSeconds")) == restart_without_position.end(),
"unavailable playback position must not be manufactured");
const auto& restart_data = RequireEventData(events.at(5), "playback-restart");
Check(
RequireSourceId(restart_data, "positioned playback-restart source ID is missing") == kFirstSourceId,
"positioned playback-restart source ID changed");
const auto& restart_position =
RequireMapField(restart_data, "positionSeconds", "finite playback position is missing");
Check(
std::holds_alternative<double>(restart_position) && std::get<double>(restart_position) == position_seconds,
"playback-restart position changed");
const auto& invalid_restart_data = RequireEventData(events.at(6), "playback-restart");
Check(
invalid_restart_data.find(flutter::EncodableValue("positionSeconds")) == invalid_restart_data.end(),
"non-finite playback position must not enter the channel payload");
const auto& end_data = RequireEventData(events.at(7), "end-file");
Check(
RequireSourceId(end_data, "end-file source ID is missing") == kEndedSourceId,
"end-file must use its event-specific source ID");
const auto& end_reason = RequireMapField(end_data, "reason", "end-file reason is missing");
Check(
std::holds_alternative<int32_t>(end_reason) && std::get<int32_t>(end_reason) == MPV_END_FILE_REASON_ERROR,
"end-file reason changed");
const auto& end_error = RequireMapField(end_data, "error", "end-file error is missing");
Check(
std::holds_alternative<int32_t>(end_error) && std::get<int32_t>(end_error) == MPV_ERROR_LOADING_FAILED,
"end-file error changed");
Check(
std::holds_alternative<std::string>(RequireMapField(end_data, "message", "end-file message is missing")),
"end-file message changed type");
const auto& next_start_data = RequireEventData(events.at(8), "start-file");
Check(
RequireSourceId(next_start_data, "replacement source ID is missing") == kNextSourceId,
"replacement source ID changed");
Check(
std::get<int64_t>(source_property[2]) == kFirstSourceId, "later START_FILE relabeled an already-queued property");
player.SetEventCallback(nullptr);
}
std::atomic<int> g_forwarded_mouse_messages{0};
std::atomic<int> g_forwarded_touch_moves{0};
std::atomic<int> g_forwarded_mouse_down_messages{0};
std::atomic<int> g_forwarded_mouse_up_messages{0};
std::atomic<int> g_forwarded_pointer_messages{0};
std::atomic<LPARAM> g_forwarded_mouse_down_position{0};
std::atomic<LPARAM> g_forwarded_mouse_up_position{0};
std::atomic<WPARAM> g_forwarded_mouse_down_flags{0};
std::atomic<WPARAM> g_forwarded_mouse_up_flags{0};
constexpr UINT kBlockWindowThreadMessage = WM_APP + 0x0505;
std::atomic<HANDLE> g_block_entered{nullptr};
std::atomic<HANDLE> g_block_release{nullptr};
std::atomic<HANDLE> 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<HWND> g_routing_view{nullptr};
std::atomic<int> g_routing_view_presses{0};
std::atomic<int> 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);
const HANDLE release = g_block_release.load(std::memory_order_acquire);
const HANDLE exited = g_block_exited.load(std::memory_order_acquire);
if (entered && release && exited) {
::SetEvent(entered);
::WaitForSingleObject(release, INFINITE);
::SetEvent(exited);
}
return 0;
}
if (message == kDispatchOnOwnerThreadMessage) {
const auto* dispatch = reinterpret_cast<const OwnerThreadDispatch*>(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);
} else {
g_forwarded_mouse_messages.fetch_add(1, std::memory_order_relaxed);
}
return 0;
}
if (message == WM_LBUTTONDOWN) {
g_forwarded_mouse_down_position.store(lparam, std::memory_order_relaxed);
g_forwarded_mouse_down_flags.store(wparam, std::memory_order_relaxed);
g_forwarded_mouse_down_messages.fetch_add(1, std::memory_order_relaxed);
return 0;
}
if (message == WM_LBUTTONUP) {
g_forwarded_mouse_up_position.store(lparam, std::memory_order_relaxed);
g_forwarded_mouse_up_flags.store(wparam, std::memory_order_relaxed);
g_forwarded_mouse_up_messages.fetch_add(1, std::memory_order_relaxed);
return 0;
}
if (message >= WM_POINTERUPDATE && message <= WM_POINTERCAPTURECHANGED) {
g_forwarded_pointer_messages.fetch_add(1, std::memory_order_relaxed);
return 0;
}
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<LPARAM>(&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<bool()>& 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<LONG>((static_cast<LONG64>(screen_point.x) * 65535) / (width - 1));
input.mi.dy = static_cast<LONG>((static_cast<LONG64>(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;
int status = MPV_ERROR_SUCCESS;
player.SetPropertyAsync("pause", "yes", [&](int error) {
++callback_count;
status = error;
});
Check(callback_count == 1, "a property write without an mpv handle must complete exactly once");
Check(status == MPV_ERROR_UNINITIALIZED, "a property write without an mpv handle must fail as uninitialized");
}
void TestPendingPropertyWriteFailsOnDispose() {
MpvPlayer player;
int callback_count = 0;
int status = MPV_ERROR_SUCCESS;
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) {
++callback_count;
status = error;
});
player.Dispose();
Check(callback_count == 1, "dispose must complete a pending property write exactly once");
Check(status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending property write as uninitialized");
player.Dispose();
Check(callback_count == 1, "repeated dispose must not complete a property write twice");
}
// The `loadfile` reply names the playlist entry mpv created for the load —
// the source id its start-file/playback-restart/end-file events carry — so the
// Dart side can bind a load to its source instead of guessing by arrival order.
void TestCommandReplyCarriesPlaylistEntryId() {
MpvPlayer player;
constexpr int64_t kEntryId = 8000000004LL;
int callback_count = 0;
int status = MPV_ERROR_SUCCESS;
int64_t reported_entry_id = 0;
bool reported_entry = false;
const uint64_t request_id =
MpvPlayerPropertyContractTestPeer::RegisterPendingCommand(player, [&](int error, const mpv_node* result) {
++callback_count;
status = error;
reported_entry = plezy::mpv_common::PlaylistEntryIdFromCommandResult(result, &reported_entry_id);
});
const char* keys[] = {"playlist_entry_id"};
mpv_node values[1]{};
values[0].format = MPV_FORMAT_INT64;
values[0].u.int64 = kEntryId;
mpv_node_list map{};
map.num = 1;
map.keys = const_cast<char**>(keys);
map.values = values;
mpv_event_command command{};
command.result.format = MPV_FORMAT_NODE_MAP;
command.result.u.list = &map;
mpv_event reply{};
reply.event_id = MPV_EVENT_COMMAND_REPLY;
reply.reply_userdata = request_id;
reply.data = &command;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &reply);
Check(callback_count == 1, "a command reply must complete its request exactly once");
Check(status == MPV_ERROR_SUCCESS, "a successful command reply changed its status");
Check(reported_entry, "the loadfile reply must expose the playlist entry id");
Check(reported_entry_id == kEntryId, "the playlist entry id lost signed 64-bit precision");
// A reply without a result map (every non-loadfile command) answers no id.
int64_t unexpected_entry_id = 0;
bool unexpected_entry = true;
const uint64_t plain_request_id =
MpvPlayerPropertyContractTestPeer::RegisterPendingCommand(player, [&](int, const mpv_node* result) {
unexpected_entry = plezy::mpv_common::PlaylistEntryIdFromCommandResult(result, &unexpected_entry_id);
});
mpv_event_command plain_command{};
plain_command.result.format = MPV_FORMAT_NONE;
reply.reply_userdata = plain_request_id;
reply.data = &plain_command;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &reply);
Check(!unexpected_entry, "a command without a result map must not report a playlist entry id");
// A failed reply must not expose whatever the event's result slot holds.
const mpv_node* failed_result = &command.result;
const uint64_t failed_request_id = MpvPlayerPropertyContractTestPeer::RegisterPendingCommand(
player, [&](int, const mpv_node* result) { failed_result = result; });
reply.reply_userdata = failed_request_id;
reply.error = MPV_ERROR_COMMAND;
reply.data = &command;
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &reply);
Check(failed_result == nullptr, "a failed command reply must not carry a result node");
// Dispose completes a pending command as uninitialized, with no result.
int cancelled_status = MPV_ERROR_SUCCESS;
const mpv_node* cancelled_result = &command.result;
MpvPlayerPropertyContractTestPeer::RegisterPendingCommand(player, [&](int error, const mpv_node* result) {
cancelled_status = error;
cancelled_result = result;
});
player.Dispose();
Check(cancelled_status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending command as uninitialized");
Check(cancelled_result == nullptr, "a cancelled command must not carry a result node");
}
void TestPendingRequestTypesRemainDistinctOnDispose() {
MpvPlayer player;
int write_count = 0;
int read_count = 0;
std::string read_value = "unexpected";
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) {
Check(error < 0, "cancelled property write must receive an error");
++write_count;
});
MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyRead(player, [&](int error, const std::string& value) {
Check(error < 0, "cancelled property read must receive an error");
++read_count;
read_value = value;
});
player.Dispose();
Check(write_count == 1, "dispose must complete the typed write request exactly once");
Check(read_count == 1, "dispose must complete the typed read request exactly once");
Check(read_value.empty(), "cancelled property reads must not manufacture a value");
}
void TestInnerSubclassOwnershipIsSerializedAndDetached() {
struct TestWindows {
HWND target;
HWND host;
HWND inner;
WNDPROC inner_original;
DWORD owner_thread;
};
std::promise<TestWindows> windows_created;
auto windows_future = windows_created.get_future();
std::thread window_owner([&]() {
HWND target =
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
const auto target_original = reinterpret_cast<WNDPROC>(
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
MSG message;
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(target_original));
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(target);
});
const TestWindows windows = windows_future.get();
Check(windows.target && windows.host && windows.inner, "test windows must be created");
MpvPlayer player;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
std::thread first([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); });
std::thread second([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); });
first.join();
second.join();
const auto installed = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
Check(installed && installed != windows.inner_original, "exactly one subclass procedure must be installed");
::SendMessageW(windows.inner, WM_NULL, 0, 0);
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(4, 7));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) {
::Sleep(10);
}
Check(g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1, "active generation must forward mouse input");
constexpr UINT32 kPrimaryPointerId = 7;
constexpr UINT32 kSecondaryPointerId = 8;
constexpr UINT32 kCancelledPointerId = 9;
constexpr UINT32 kDetachedPointerId = 10;
constexpr UINT32 kDestroyedPointerId = 11;
constexpr UINT32 kPointerDownFlags = POINTER_MESSAGE_FLAG_NEW | POINTER_MESSAGE_FLAG_INRANGE |
POINTER_MESSAGE_FLAG_INCONTACT | POINTER_MESSAGE_FLAG_FIRSTBUTTON |
POINTER_MESSAGE_FLAG_PRIMARY;
constexpr UINT32 kPointerMoveFlags = POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_INCONTACT |
POINTER_MESSAGE_FLAG_FIRSTBUTTON | POINTER_MESSAGE_FLAG_PRIMARY;
constexpr UINT32 kPointerUpFlags = POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_PRIMARY;
POINT down_position = {14, 18};
::ClientToScreen(windows.inner, &down_position);
POINT expected_down_position = down_position;
::ScreenToClient(windows.target, &expected_down_position);
POINT up_position = {30, 36};
::ClientToScreen(windows.inner, &up_position);
POINT expected_up_position = up_position;
::ScreenToClient(windows.target, &expected_up_position);
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kPrimaryPointerId, kPointerDownFlags),
MAKELPARAM(down_position.x, down_position.y));
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERUPDATE, MAKEWPARAM(kPrimaryPointerId, kPointerMoveFlags),
MAKELPARAM(up_position.x, up_position.y));
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);
}
Check(g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 1, "primary touch must press once");
Check(g_forwarded_touch_moves.load(std::memory_order_relaxed) == 1, "primary touch movement must drag once");
Check(g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 1, "primary touch must release once");
Check(
g_forwarded_pointer_messages.load(std::memory_order_relaxed) == 0, "raw pointer messages must not reach Flutter");
Check(
g_forwarded_mouse_down_flags.load(std::memory_order_relaxed) == MK_LBUTTON,
"touch down must hold the mouse button");
Check(g_forwarded_mouse_up_flags.load(std::memory_order_relaxed) == 0, "touch up must release the mouse button");
const LPARAM forwarded_down_position = g_forwarded_mouse_down_position.load(std::memory_order_relaxed);
Check(
GET_X_LPARAM(forwarded_down_position) == expected_down_position.x &&
GET_Y_LPARAM(forwarded_down_position) == expected_down_position.y,
"touch down must use Flutter-view client coordinates");
const LPARAM forwarded_up_position = g_forwarded_mouse_up_position.load(std::memory_order_relaxed);
Check(
GET_X_LPARAM(forwarded_up_position) == expected_up_position.x &&
GET_Y_LPARAM(forwarded_up_position) == expected_up_position.y,
"touch up must use Flutter-view client coordinates");
const WPARAM secondary_down = MAKEWPARAM(kSecondaryPointerId, kPointerDownFlags & ~POINTER_MESSAGE_FLAG_PRIMARY);
const WPARAM secondary_up = MAKEWPARAM(kSecondaryPointerId, kPointerUpFlags & ~POINTER_MESSAGE_FLAG_PRIMARY);
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");
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kCancelledPointerId, kPointerDownFlags),
MAKELPARAM(down_position.x, down_position.y));
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERCAPTURECHANGED, MAKEWPARAM(kCancelledPointerId, kPointerUpFlags),
reinterpret_cast<LPARAM>(windows.host));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 2; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 2 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 2,
"capture loss must release an active synthetic mouse press");
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) {
::Sleep(10);
}
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(player);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"detach must restore the original procedure before window destruction");
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 3; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 3 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3,
"detach must release an active synthetic mouse press");
SendFromOwnerThread(
windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kDetachedPointerId, kPointerUpFlags),
MAKELPARAM(up_position.x, up_position.y));
::Sleep(30);
Check(
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3,
"a late pointer up after detach must not release twice");
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(8, 9));
::Sleep(30);
Check(
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1,
"a callback after detaching the old generation must be ignored");
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
const auto replacement = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
Check(replacement && replacement != windows.inner_original, "a replacement generation must install cleanly");
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(10, 11));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 2; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 2,
"replacement generation must own forwarding after installation");
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"replacement detach must restore the original procedure");
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
const auto destroy_generation = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC));
Check(
destroy_generation && destroy_generation != windows.inner_original,
"destroy test must install a fresh subclass generation");
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) {
::Sleep(10);
}
Check(!::IsWindow(windows.inner), "destroy test must close the inner window");
Check(
g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 4 &&
g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 4,
"window destruction must release an active synthetic mouse press");
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
window_owner.join();
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
g_forwarded_touch_moves.store(0, std::memory_order_relaxed);
g_forwarded_mouse_down_messages.store(0, std::memory_order_relaxed);
g_forwarded_mouse_up_messages.store(0, std::memory_order_relaxed);
g_forwarded_pointer_messages.store(0, std::memory_order_relaxed);
}
void TestTimedOutSubclassDetachCanBeAdopted() {
struct TestWindows {
HWND target;
HWND host;
HWND inner;
WNDPROC inner_original;
DWORD owner_thread;
};
std::promise<TestWindows> windows_created;
auto windows_future = windows_created.get_future();
std::thread window_owner([&]() {
HWND target =
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
const auto target_original = reinterpret_cast<WNDPROC>(
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
MSG message;
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(target_original));
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(target);
});
const TestWindows windows = windows_future.get();
Check(windows.target && windows.host && windows.inner, "detach-timeout test windows must be created");
const HANDLE block_entered = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
const HANDLE block_release = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
const HANDLE block_exited = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);
Check(block_entered && block_release && block_exited, "detach-timeout synchronization events must be created");
g_block_entered.store(block_entered, std::memory_order_release);
g_block_release.store(block_release, std::memory_order_release);
g_block_exited.store(block_exited, std::memory_order_release);
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
{
MpvPlayer original;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(original, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(original);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original,
"the initial generation must be installed before forcing detach timeout");
const void* retained_generation = MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(original);
Check(retained_generation != nullptr, "the initial generation must have live state");
Check(
::PostMessageW(windows.target, kBlockWindowThreadMessage, 0, 0) != FALSE,
"the owner-thread blocking message must be posted");
Check(
::WaitForSingleObject(block_entered, 1000) == WAIT_OBJECT_0,
"the owner thread must enter the deterministic blocking message");
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(original);
MpvPlayer replacement;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(replacement, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(replacement);
Check(
MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(replacement) == retained_generation,
"replacement must atomically adopt the retained generation");
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original,
"replacement must adopt the retained installed generation without duplicate subclassing");
::SetEvent(block_release);
Check(
::WaitForSingleObject(block_exited, 1000) == WAIT_OBJECT_0,
"the owner thread must leave the deterministic blocking message");
::SendMessageW(windows.inner, WM_NULL, 0, 0);
::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(12, 13));
for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) {
::Sleep(10);
}
Check(
g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1,
"the adopted generation must resume input forwarding");
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(replacement);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"adopted generation cleanup must eventually restore the original procedure");
}
g_block_entered.store(nullptr, std::memory_order_release);
g_block_release.store(nullptr, std::memory_order_release);
g_block_exited.store(nullptr, std::memory_order_release);
::CloseHandle(block_entered);
::CloseHandle(block_release);
::CloseHandle(block_exited);
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
window_owner.join();
g_forwarded_mouse_messages.store(0, std::memory_order_relaxed);
}
void TestTimedOutSubclassInstallCannotOutliveItsState() {
struct TestWindows {
HWND target;
HWND host;
HWND inner;
WNDPROC inner_original;
DWORD owner_thread;
};
std::promise<TestWindows> windows_created;
auto windows_future = windows_created.get_future();
std::promise<void> begin_dispatch;
auto begin_dispatch_future = begin_dispatch.get_future();
std::thread window_owner([&]() {
HWND target =
::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr);
HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr);
HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr);
const auto inner_original = reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(inner, GWLP_WNDPROC));
windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()});
// Keep the owning thread alive but unavailable long enough for
// SendMessageTimeoutW to cancel the cross-thread ownership action.
begin_dispatch_future.wait();
MSG message;
while (::GetMessageW(&message, nullptr, 0, 0) > 0) {
::TranslateMessage(&message);
::DispatchMessageW(&message);
}
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(target);
});
const TestWindows windows = windows_future.get();
Check(windows.target && windows.host && windows.inner, "timeout test windows must be created");
{
MpvPlayer player;
MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target);
MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player);
MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player);
}
// The action and its subclass reference data have now left caller scope.
// Dispatching the timed-out message must neither install late nor touch the
// destroyed caller state.
begin_dispatch.set_value();
::SendMessageW(windows.inner, WM_NULL, 0, 0);
Check(
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
"a timed-out action must remain cancelled after the window thread resumes");
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
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<WNDPROC>(::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
const auto host_original =
reinterpret_cast<WNDPROC>(::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
const auto inner_original = reinterpret_cast<WNDPROC>(
::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(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<LONG_PTR>(inner_original));
::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(host_original));
::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(view_original));
g_routing_view.store(nullptr, std::memory_order_release);
::DestroyWindow(inner);
::DestroyWindow(host);
::DestroyWindow(view);
}
} // namespace
} // namespace mpv
int main() {
mpv::TestSourceQualifiedEventPayloads();
mpv::TestUnavailablePropertyWriteFails();
mpv::TestPendingPropertyWriteFailsOnDispose();
mpv::TestCommandReplyCarriesPlaylistEntryId();
mpv::TestPendingRequestTypesRemainDistinctOnDispose();
mpv::TestInnerSubclassOwnershipIsSerializedAndDetached();
mpv::TestTimedOutSubclassDetachCanBeAdopted();
mpv::TestTimedOutSubclassInstallCannotOutliveItsState();
mpv::TestDisabledVideoHostKeepsInputOnTheFlutterView();
mpv::TestDisabledVideoHostRoutesRealPressesToParentView();
std::cout << "mpv_player_property_contract_test: PASS\n";
return 0;
}