fix(native): bound cross-platform lifecycle ownership
This commit is contained in:
@@ -77,7 +77,7 @@ if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS)
|
||||
target_compile_definitions(mpv_player_property_contract_test PRIVATE "NOMINMAX")
|
||||
target_link_libraries(
|
||||
mpv_player_property_contract_test
|
||||
PRIVATE flutter "${MPV_LIB_DIR}/libmpv.dll.a" simdutf "user32.lib"
|
||||
PRIVATE flutter_wrapper_plugin "${MPV_LIB_DIR}/libmpv.dll.a" simdutf "comctl32.lib" "user32.lib"
|
||||
)
|
||||
target_include_directories(
|
||||
mpv_player_property_contract_test
|
||||
@@ -101,3 +101,21 @@ if(PLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS)
|
||||
add_test(NAME mpv_property_result_contract_test COMMAND mpv_property_result_contract_test)
|
||||
add_test(NAME mpv_player_property_contract_test COMMAND mpv_player_property_contract_test)
|
||||
endif()
|
||||
|
||||
option(PLEZY_BUILD_DISPLAY_RECOVERY_TESTS
|
||||
"Build the focused Windows display recovery transaction tests" OFF)
|
||||
if(PLEZY_BUILD_DISPLAY_RECOVERY_TESTS)
|
||||
enable_testing()
|
||||
|
||||
add_executable(display_mode_manager_test
|
||||
"mpv/display_mode_manager.cpp"
|
||||
"mpv/display_mode_manager_test.cpp"
|
||||
)
|
||||
apply_standard_settings(display_mode_manager_test)
|
||||
target_compile_definitions(
|
||||
display_mode_manager_test PRIVATE "NOMINMAX" "PLEZY_DISPLAY_MODE_MANAGER_TESTING"
|
||||
)
|
||||
target_link_libraries(display_mode_manager_test PRIVATE "advapi32.lib" "user32.lib")
|
||||
|
||||
add_test(NAME display_mode_manager_test COMMAND display_mode_manager_test)
|
||||
endif()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <optional>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
#include "mpv/display_mode_manager.h"
|
||||
#include "mpv/mpv_plugin.h"
|
||||
|
||||
// Registry key for window placement persistence
|
||||
@@ -158,6 +159,10 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam
|
||||
}
|
||||
|
||||
switch (message) {
|
||||
case WM_DISPLAYCHANGE:
|
||||
// One bounded, serialized retry for a display that may have reconnected.
|
||||
mpv::DisplayModeManager::RecoverIfNeeded();
|
||||
break;
|
||||
case WM_FONTCHANGE:
|
||||
flutter_controller_->engine()->ReloadSystemFonts();
|
||||
break;
|
||||
|
||||
@@ -54,7 +54,7 @@ wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t* command
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
// Recover display mode if a prior crash left it changed.
|
||||
mpv::DisplayModeManager::RecoverIfNeeded(::GetAncestor(window.GetHandle(), GA_ROOT));
|
||||
mpv::DisplayModeManager::RecoverIfNeeded();
|
||||
|
||||
::MSG msg;
|
||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||
|
||||
@@ -2,19 +2,53 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#include "sdk_26100.h"
|
||||
|
||||
namespace mpv {
|
||||
|
||||
static const wchar_t* kRegistryPath = L"Software\\Plezy\\DisplayModeOverride";
|
||||
static const wchar_t* kRegDeviceName = L"DeviceName";
|
||||
static const wchar_t* kRegOriginalRefreshRate = L"OriginalRefreshRate";
|
||||
static const wchar_t* kRegOriginalWidth = L"OriginalWidth";
|
||||
static const wchar_t* kRegOriginalHeight = L"OriginalHeight";
|
||||
static const wchar_t* kRegOriginalHDR = L"OriginalHDREnabled";
|
||||
static const wchar_t* kRegModeChanged = L"ModeChanged";
|
||||
static const wchar_t* kRegHDRChanged = L"HDRChanged";
|
||||
namespace {
|
||||
|
||||
constexpr wchar_t kRegistryPath[] = L"Software\\Plezy\\DisplayModeOverride";
|
||||
constexpr wchar_t kRegVersion[] = L"Version";
|
||||
constexpr DWORD kRecoveryVersion = 1;
|
||||
constexpr wchar_t kRegModeDeviceName[] = L"ModeDeviceName";
|
||||
constexpr wchar_t kRegLegacyDeviceName[] = L"DeviceName";
|
||||
constexpr wchar_t kRegHDRDeviceName[] = L"HDRDeviceName";
|
||||
constexpr wchar_t kRegOriginalRefreshRate[] = L"OriginalRefreshRate";
|
||||
constexpr wchar_t kRegOriginalWidth[] = L"OriginalWidth";
|
||||
constexpr wchar_t kRegOriginalHeight[] = L"OriginalHeight";
|
||||
constexpr wchar_t kRegOriginalHDR[] = L"OriginalHDREnabled";
|
||||
constexpr wchar_t kRegModeChanged[] = L"ModeChanged";
|
||||
constexpr wchar_t kRegHDRChanged[] = L"HDRChanged";
|
||||
|
||||
std::recursive_mutex g_display_override_mutex;
|
||||
bool g_live_mode_recovery_record = false;
|
||||
bool g_live_hdr_recovery_record = false;
|
||||
bool g_recovery_in_progress = false;
|
||||
|
||||
class RecoveryRunGuard {
|
||||
public:
|
||||
RecoveryRunGuard() : acquired_(!g_recovery_in_progress) {
|
||||
if (acquired_) g_recovery_in_progress = true;
|
||||
}
|
||||
~RecoveryRunGuard() {
|
||||
if (acquired_) g_recovery_in_progress = false;
|
||||
}
|
||||
|
||||
bool acquired() const { return acquired_; }
|
||||
|
||||
private:
|
||||
bool acquired_;
|
||||
};
|
||||
|
||||
bool PrepareModeRecoveryAtRegistry(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate);
|
||||
bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled);
|
||||
bool CompleteRecoveryOperationAtRegistry(const wchar_t* marker);
|
||||
|
||||
} // namespace
|
||||
|
||||
DisplayModeManager::DisplayModeManager() {}
|
||||
|
||||
@@ -149,13 +183,27 @@ void DisplayModeManager::SaveOriginalMode(HWND window) {
|
||||
}
|
||||
|
||||
bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height, DWORD refresh_rate) {
|
||||
std::wstring device_name = GetMonitorDeviceName(window);
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
|
||||
const std::wstring device_name = GetMonitorDeviceName(window);
|
||||
if (device_name.empty()) return false;
|
||||
|
||||
// Save original mode if not already saved.
|
||||
if (!mode_changed_) {
|
||||
SaveOriginalMode(window);
|
||||
const bool mode_was_changed = mode_changed_;
|
||||
if (!mode_changed_) SaveOriginalMode(window);
|
||||
if (original_device_name_.empty() || original_devmode_.dmPelsWidth == 0 || original_devmode_.dmPelsHeight == 0 ||
|
||||
original_devmode_.dmDisplayFrequency == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!PrepareModeRecoveryAtRegistry(
|
||||
original_device_name_, original_devmode_.dmPelsWidth, original_devmode_.dmPelsHeight,
|
||||
original_devmode_.dmDisplayFrequency)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark the record live before calling Windows. ChangeDisplaySettingsExW can
|
||||
// synchronously deliver WM_DISPLAYCHANGE; that event must not recover the
|
||||
// override that is currently being applied.
|
||||
g_live_mode_recovery_record = true;
|
||||
|
||||
DEVMODEW dm = {};
|
||||
dm.dmSize = sizeof(dm);
|
||||
@@ -187,37 +235,49 @@ bool DisplayModeManager::SetDisplayMode(HWND window, DWORD width, DWORD height,
|
||||
|
||||
// Standard path / fallback.
|
||||
if (!changed) {
|
||||
LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
if (rc == DISP_CHANGE_SUCCESSFUL) changed = true;
|
||||
const LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
changed = rc == DISP_CHANGE_SUCCESSFUL;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
mode_changed_ = true;
|
||||
WriteRecoveryState();
|
||||
} else if (!mode_was_changed) {
|
||||
// Do not discard an independently persisted HDR operation owned by
|
||||
// another manager or retained from startup recovery.
|
||||
CompleteRecoveryOperationAtRegistry(kRegModeChanged);
|
||||
g_live_mode_recovery_record = false;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool DisplayModeManager::RestoreOriginalMode(HWND window) {
|
||||
if (!mode_changed_ || original_device_name_.empty()) return false;
|
||||
bool DisplayModeManager::RestoreOriginalMode(HWND) {
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
if (!mode_changed_) return false;
|
||||
if (original_device_name_.empty()) {
|
||||
g_live_mode_recovery_record = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
original_devmode_.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||
|
||||
LONG rc =
|
||||
ChangeDisplaySettingsExW(original_device_name_.c_str(), &original_devmode_, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
|
||||
if (rc == DISP_CHANGE_SUCCESSFUL) {
|
||||
mode_changed_ = false;
|
||||
if (!hdr_changed_) ClearRecoveryState();
|
||||
return true;
|
||||
if (rc != DISP_CHANGE_SUCCESSFUL) {
|
||||
// Fallback: restore registry defaults.
|
||||
rc = ChangeDisplaySettingsExW(original_device_name_.c_str(), nullptr, nullptr, 0, nullptr);
|
||||
}
|
||||
if (rc != DISP_CHANGE_SUCCESSFUL) {
|
||||
// The explicit owner has given up. Keep the durable marker, but release it
|
||||
// so a later topology notification can restore a reconnected target.
|
||||
g_live_mode_recovery_record = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback: restore registry defaults.
|
||||
rc = ChangeDisplaySettingsExW(original_device_name_.c_str(), nullptr, nullptr, 0, nullptr);
|
||||
mode_changed_ = (rc != DISP_CHANGE_SUCCESSFUL);
|
||||
if (!mode_changed_ && !hdr_changed_) ClearRecoveryState();
|
||||
return rc == DISP_CHANGE_SUCCESSFUL;
|
||||
mode_changed_ = false;
|
||||
CompleteRecoveryOperationAtRegistry(kRegModeChanged);
|
||||
g_live_mode_recovery_record = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- HDR ---
|
||||
@@ -297,26 +357,39 @@ void DisplayModeManager::SaveOriginalHDRState(HWND window) {
|
||||
}
|
||||
|
||||
bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
|
||||
std::wstring device_name = GetMonitorDeviceName(window);
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
|
||||
const std::wstring device_name = GetMonitorDeviceName(window);
|
||||
if (device_name.empty()) return false;
|
||||
|
||||
auto target_id = GetDisplayTargetId(device_name);
|
||||
const auto target_id = GetDisplayTargetId(device_name);
|
||||
if (!target_id) return false;
|
||||
|
||||
// Save original state if not already saved.
|
||||
if (!hdr_changed_) {
|
||||
SaveOriginalHDRState(window);
|
||||
const bool hdr_was_changed = hdr_changed_;
|
||||
if (!hdr_changed_) SaveOriginalHDRState(window);
|
||||
if (original_hdr_device_name_.empty() ||
|
||||
!PrepareHDRRecoveryAtRegistry(original_hdr_device_name_, original_hdr_enabled_)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// See SetDisplayMode: keep synchronous topology notifications from treating
|
||||
// this process's just-persisted marker as crash recovery.
|
||||
g_live_hdr_recovery_record = true;
|
||||
|
||||
// Save DEVMODEW before toggle — Windows changes display mode on HDR state change.
|
||||
// Source: Kodi WIN32Util.cpp:1252-1257.
|
||||
DEVMODEW pre_toggle_dm = {};
|
||||
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
||||
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
||||
|
||||
// Toggle HDR.
|
||||
LONG result = SetHDRStateForTarget(*target_id, enabled);
|
||||
if (result != ERROR_SUCCESS) return false;
|
||||
const LONG result = SetHDRStateForTarget(*target_id, enabled);
|
||||
if (result != ERROR_SUCCESS) {
|
||||
if (!hdr_was_changed) {
|
||||
CompleteRecoveryOperationAtRegistry(kRegHDRChanged);
|
||||
g_live_hdr_recovery_record = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Restore DEVMODEW after toggle — Windows may have changed the display mode.
|
||||
// Source: Kodi WIN32Util.cpp:1276-1288.
|
||||
@@ -326,40 +399,48 @@ bool DisplayModeManager::SetHDREnabled(HWND window, bool enabled) {
|
||||
}
|
||||
|
||||
hdr_changed_ = true;
|
||||
WriteRecoveryState();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DisplayModeManager::RestoreOriginalHDRState(HWND window) {
|
||||
if (!hdr_changed_ || original_hdr_device_name_.empty()) return false;
|
||||
|
||||
bool current = IsHDREnabled(window);
|
||||
if (current == original_hdr_enabled_) {
|
||||
hdr_changed_ = false;
|
||||
if (!mode_changed_) ClearRecoveryState();
|
||||
return true;
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
if (!hdr_changed_) return false;
|
||||
if (original_hdr_device_name_.empty()) {
|
||||
g_live_hdr_recovery_record = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Need to actually toggle back.
|
||||
auto target_id = GetDisplayTargetId(original_hdr_device_name_);
|
||||
if (!target_id) return false;
|
||||
const auto target_id = GetDisplayTargetId(original_hdr_device_name_);
|
||||
if (!target_id) {
|
||||
g_live_hdr_recovery_record = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save DEVMODEW before restore toggle.
|
||||
DEVMODEW pre_toggle_dm = {};
|
||||
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
||||
EnumDisplaySettingsW(original_hdr_device_name_.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
||||
if (IsHDREnabled(window) != original_hdr_enabled_) {
|
||||
// The original target was resolved above even when the currently observed
|
||||
// HDR state already matches, so a disconnected target cannot be mistaken
|
||||
// for a successful restore.
|
||||
|
||||
LONG result = SetHDRStateForTarget(*target_id, original_hdr_enabled_);
|
||||
if (result != ERROR_SUCCESS) return false;
|
||||
// Save DEVMODEW before restore toggle.
|
||||
DEVMODEW pre_toggle_dm = {};
|
||||
pre_toggle_dm.dmSize = sizeof(pre_toggle_dm);
|
||||
EnumDisplaySettingsW(original_hdr_device_name_.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_dm);
|
||||
|
||||
// Restore DEVMODEW after toggle.
|
||||
if (pre_toggle_dm.dmDisplayFrequency != 0) {
|
||||
pre_toggle_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||
ChangeDisplaySettingsExW(original_hdr_device_name_.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
if (SetHDRStateForTarget(*target_id, original_hdr_enabled_) != ERROR_SUCCESS) {
|
||||
g_live_hdr_recovery_record = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Restore DEVMODEW after toggle.
|
||||
if (pre_toggle_dm.dmDisplayFrequency != 0) {
|
||||
pre_toggle_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||
ChangeDisplaySettingsExW(original_hdr_device_name_.c_str(), &pre_toggle_dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
hdr_changed_ = false;
|
||||
if (!mode_changed_) ClearRecoveryState();
|
||||
CompleteRecoveryOperationAtRegistry(kRegHDRChanged);
|
||||
g_live_hdr_recovery_record = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -385,152 +466,410 @@ LONG DisplayModeManager::SetHDRStateForTarget(const DisplayConfigId& target, boo
|
||||
}
|
||||
}
|
||||
|
||||
bool DisplayModeManager::WriteRegistryDWORD(const wchar_t* value_name, DWORD value) {
|
||||
namespace {
|
||||
|
||||
bool WriteRegistryDWORD(const wchar_t* value_name, DWORD value) {
|
||||
HKEY key;
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) !=
|
||||
ERROR_SUCCESS)
|
||||
ERROR_SUCCESS) {
|
||||
return false;
|
||||
LONG result = RegSetValueExW(key, value_name, 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value));
|
||||
}
|
||||
const LONG result =
|
||||
RegSetValueExW(key, value_name, 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value));
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
bool DisplayModeManager::WriteRegistryString(const wchar_t* value_name, const std::wstring& value) {
|
||||
bool WriteRegistryString(const wchar_t* value_name, const std::wstring& value) {
|
||||
HKEY key;
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) !=
|
||||
ERROR_SUCCESS)
|
||||
ERROR_SUCCESS) {
|
||||
return false;
|
||||
LONG result = RegSetValueExW(
|
||||
}
|
||||
const LONG result = RegSetValueExW(
|
||||
key, value_name, 0, REG_SZ, reinterpret_cast<const BYTE*>(value.c_str()),
|
||||
static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t)));
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
bool DisplayModeManager::ReadRegistryDWORD(const wchar_t* value_name, DWORD& value) {
|
||||
bool ReadRegistryDWORD(const wchar_t* value_name, DWORD& value) {
|
||||
HKEY key;
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false;
|
||||
DWORD size = sizeof(value);
|
||||
DWORD type = 0;
|
||||
LONG result = RegQueryValueExW(key, value_name, nullptr, &type, reinterpret_cast<BYTE*>(&value), &size);
|
||||
const LONG result = RegQueryValueExW(key, value_name, nullptr, &type, reinterpret_cast<BYTE*>(&value), &size);
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS && type == REG_DWORD;
|
||||
return result == ERROR_SUCCESS && type == REG_DWORD && size == sizeof(value);
|
||||
}
|
||||
|
||||
bool DisplayModeManager::ReadRegistryString(const wchar_t* value_name, std::wstring& value) {
|
||||
bool ReadRegistryString(const wchar_t* value_name, std::wstring& value) {
|
||||
HKEY key;
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_READ, &key) != ERROR_SUCCESS) return false;
|
||||
DWORD size = 0;
|
||||
DWORD type = 0;
|
||||
RegQueryValueExW(key, value_name, nullptr, &type, nullptr, &size);
|
||||
if (type != REG_SZ || size == 0) {
|
||||
const LONG size_result = RegQueryValueExW(key, value_name, nullptr, &type, nullptr, &size);
|
||||
if (size_result != ERROR_SUCCESS || type != REG_SZ || size == 0 || size % sizeof(wchar_t) != 0) {
|
||||
RegCloseKey(key);
|
||||
return false;
|
||||
}
|
||||
value.resize(size / sizeof(wchar_t));
|
||||
LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, reinterpret_cast<BYTE*>(&value[0]), &size);
|
||||
const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, reinterpret_cast<BYTE*>(value.data()), &size);
|
||||
RegCloseKey(key);
|
||||
if (result != ERROR_SUCCESS) return false;
|
||||
// Remove trailing null.
|
||||
while (!value.empty() && value.back() == L'\0') value.pop_back();
|
||||
return true;
|
||||
return !value.empty();
|
||||
}
|
||||
|
||||
bool DisplayModeManager::DeleteRegistryValue(const wchar_t* value_name) {
|
||||
bool RecoveryRecordExists() {
|
||||
HKEY key;
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_WRITE, &key) != ERROR_SUCCESS) return false;
|
||||
RegDeleteValueW(key, value_name);
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_QUERY_VALUE, &key) != ERROR_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
bool exists = false;
|
||||
for (const wchar_t* value_name :
|
||||
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate,
|
||||
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) {
|
||||
DWORD size = 0;
|
||||
const LONG result = RegQueryValueExW(key, value_name, nullptr, nullptr, nullptr, &size);
|
||||
if (result == ERROR_SUCCESS || result == ERROR_MORE_DATA) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
RegCloseKey(key);
|
||||
return exists;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class Win32DisplayRecoveryBackend final : public DisplayRecoveryBackend {
|
||||
public:
|
||||
bool RecordExists() const override { return RecoveryRecordExists(); }
|
||||
|
||||
bool ReadDWORD(const wchar_t* value_name, DWORD& value) override { return ReadRegistryDWORD(value_name, value); }
|
||||
|
||||
bool ReadString(const wchar_t* value_name, std::wstring& value) override {
|
||||
return ReadRegistryString(value_name, value);
|
||||
}
|
||||
|
||||
bool WriteDWORD(const wchar_t* value_name, DWORD value) override { return WriteRegistryDWORD(value_name, value); }
|
||||
|
||||
bool WriteString(const wchar_t* value_name, const std::wstring& value) override {
|
||||
return WriteRegistryString(value_name, value);
|
||||
}
|
||||
|
||||
bool IsDevicePresent(const std::wstring& device_name) const override {
|
||||
DEVMODEW mode = {};
|
||||
mode.dmSize = sizeof(mode);
|
||||
return EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &mode) != FALSE;
|
||||
}
|
||||
|
||||
bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) override {
|
||||
DEVMODEW dm = {};
|
||||
dm.dmSize = sizeof(dm);
|
||||
dm.dmPelsWidth = width;
|
||||
dm.dmPelsHeight = height;
|
||||
dm.dmDisplayFrequency = refresh_rate;
|
||||
dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY;
|
||||
return ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr) ==
|
||||
DISP_CHANGE_SUCCESSFUL;
|
||||
}
|
||||
|
||||
bool RestoreHDR(const std::wstring& device_name, bool enabled) override {
|
||||
const auto target_id = DisplayModeManager::GetDisplayTargetId(device_name);
|
||||
if (!target_id) return false;
|
||||
|
||||
DEVMODEW pre_toggle_mode = {};
|
||||
pre_toggle_mode.dmSize = sizeof(pre_toggle_mode);
|
||||
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_toggle_mode);
|
||||
|
||||
if (DisplayModeManager::SetHDRStateForTarget(*target_id, enabled) != ERROR_SUCCESS) return false;
|
||||
|
||||
if (pre_toggle_mode.dmDisplayFrequency != 0) {
|
||||
pre_toggle_mode.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||
if (ChangeDisplaySettingsExW(device_name.c_str(), &pre_toggle_mode, nullptr, CDS_FULLSCREEN, nullptr) !=
|
||||
DISP_CHANGE_SUCCESSFUL) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ClearMarker(const wchar_t* value_name) override { return WriteRegistryDWORD(value_name, 0); }
|
||||
|
||||
bool DeleteRecord() override {
|
||||
HKEY key;
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kRegistryPath, 0, KEY_SET_VALUE, &key) != ERROR_SUCCESS) {
|
||||
return !RecordExists();
|
||||
}
|
||||
|
||||
bool deleted = true;
|
||||
for (const wchar_t* value_name :
|
||||
{kRegVersion, kRegModeDeviceName, kRegLegacyDeviceName, kRegHDRDeviceName, kRegOriginalRefreshRate,
|
||||
kRegOriginalWidth, kRegOriginalHeight, kRegOriginalHDR, kRegModeChanged, kRegHDRChanged}) {
|
||||
const LONG result = RegDeleteValueW(key, value_name);
|
||||
deleted = deleted && (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND);
|
||||
}
|
||||
RegCloseKey(key);
|
||||
return deleted;
|
||||
}
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
bool ReadValidModeValues(
|
||||
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& width,
|
||||
DWORD& height, DWORD& refresh_rate) {
|
||||
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalWidth, width) &&
|
||||
width > 0 && backend.ReadDWORD(kRegOriginalHeight, height) && height > 0 &&
|
||||
backend.ReadDWORD(kRegOriginalRefreshRate, refresh_rate) && refresh_rate > 0;
|
||||
}
|
||||
|
||||
bool ReadValidHDRValues(
|
||||
DisplayRecoveryBackend& backend, const wchar_t* device_value_name, std::wstring& device_name, DWORD& original_hdr) {
|
||||
return backend.ReadString(device_value_name, device_name) && backend.ReadDWORD(kRegOriginalHDR, original_hdr) &&
|
||||
original_hdr <= 1;
|
||||
}
|
||||
|
||||
bool ReadValidMarkedMode(
|
||||
DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& width, DWORD& height, DWORD& refresh_rate) {
|
||||
DWORD version = 0;
|
||||
DWORD marker = 0;
|
||||
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion &&
|
||||
backend.ReadDWORD(kRegModeChanged, marker) && marker == 1 &&
|
||||
ReadValidModeValues(backend, kRegModeDeviceName, device_name, width, height, refresh_rate);
|
||||
}
|
||||
|
||||
bool ReadValidMarkedHDR(DisplayRecoveryBackend& backend, std::wstring& device_name, DWORD& original_hdr) {
|
||||
DWORD version = 0;
|
||||
DWORD marker = 0;
|
||||
return backend.ReadDWORD(kRegVersion, version) && version == kRecoveryVersion &&
|
||||
backend.ReadDWORD(kRegHDRChanged, marker) && marker == 1 &&
|
||||
ReadValidHDRValues(backend, kRegHDRDeviceName, device_name, original_hdr);
|
||||
}
|
||||
|
||||
bool DeleteRecordIfNoMarkedOperations(DisplayRecoveryBackend& backend) {
|
||||
DWORD mode_marker = 0;
|
||||
DWORD hdr_marker = 0;
|
||||
if (!backend.ReadDWORD(kRegModeChanged, mode_marker) || !backend.ReadDWORD(kRegHDRChanged, hdr_marker) ||
|
||||
mode_marker != 0 || hdr_marker != 0) {
|
||||
// Missing, malformed, or active evidence is retained conservatively.
|
||||
return false;
|
||||
}
|
||||
// Deletion is best effort after both operation markers are durably clear.
|
||||
backend.DeleteRecord();
|
||||
return true;
|
||||
}
|
||||
|
||||
void DisplayModeManager::WriteRecoveryState() {
|
||||
std::wstring device = mode_changed_ ? original_device_name_ : original_hdr_device_name_;
|
||||
if (device.empty()) return;
|
||||
|
||||
WriteRegistryString(kRegDeviceName, device);
|
||||
WriteRegistryDWORD(kRegModeChanged, mode_changed_ ? 1 : 0);
|
||||
WriteRegistryDWORD(kRegHDRChanged, hdr_changed_ ? 1 : 0);
|
||||
|
||||
if (mode_changed_) {
|
||||
WriteRegistryDWORD(kRegOriginalRefreshRate, original_devmode_.dmDisplayFrequency);
|
||||
WriteRegistryDWORD(kRegOriginalWidth, original_devmode_.dmPelsWidth);
|
||||
WriteRegistryDWORD(kRegOriginalHeight, original_devmode_.dmPelsHeight);
|
||||
}
|
||||
|
||||
if (hdr_changed_) {
|
||||
WriteRegistryDWORD(kRegOriginalHDR, original_hdr_enabled_ ? 1 : 0);
|
||||
}
|
||||
bool CompleteRecoveryOperation(DisplayRecoveryBackend& backend, const wchar_t* marker) {
|
||||
if (!backend.ClearMarker(marker)) return false;
|
||||
DeleteRecordIfNoMarkedOperations(backend);
|
||||
return true;
|
||||
}
|
||||
|
||||
void DisplayModeManager::ClearRecoveryState() {
|
||||
// Delete the entire key.
|
||||
RegDeleteKeyW(HKEY_CURRENT_USER, kRegistryPath);
|
||||
}
|
||||
bool PreserveValidModeSiblingOrClear(DisplayRecoveryBackend& backend) {
|
||||
DWORD marker = 0;
|
||||
if (!backend.ReadDWORD(kRegModeChanged, marker)) {
|
||||
return backend.WriteDWORD(kRegModeChanged, 0);
|
||||
}
|
||||
if (marker == 0) return true;
|
||||
|
||||
bool DisplayModeManager::RecoverIfNeeded(HWND window) {
|
||||
DWORD mode_changed = 0, hdr_changed = 0;
|
||||
std::wstring device_name;
|
||||
DWORD width = 0;
|
||||
DWORD height = 0;
|
||||
DWORD refresh_rate = 0;
|
||||
if (marker == 1 && ReadValidMarkedMode(backend, device_name, width, height, refresh_rate)) {
|
||||
return true;
|
||||
}
|
||||
return backend.ClearMarker(kRegModeChanged);
|
||||
}
|
||||
|
||||
if (!ReadRegistryString(kRegDeviceName, device_name)) return false;
|
||||
ReadRegistryDWORD(kRegModeChanged, mode_changed);
|
||||
ReadRegistryDWORD(kRegHDRChanged, hdr_changed);
|
||||
bool PreserveValidHDRSiblingOrClear(DisplayRecoveryBackend& backend) {
|
||||
DWORD marker = 0;
|
||||
if (!backend.ReadDWORD(kRegHDRChanged, marker)) {
|
||||
return backend.WriteDWORD(kRegHDRChanged, 0);
|
||||
}
|
||||
if (marker == 0) return true;
|
||||
|
||||
if (!mode_changed && !hdr_changed) {
|
||||
RegDeleteKeyW(HKEY_CURRENT_USER, kRegistryPath);
|
||||
std::wstring device_name;
|
||||
DWORD original_hdr = 0;
|
||||
if (marker == 1 && ReadValidMarkedHDR(backend, device_name, original_hdr)) {
|
||||
return true;
|
||||
}
|
||||
return backend.ClearMarker(kRegHDRChanged);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool DisplayModeManager::PrepareModeRecovery(
|
||||
DisplayRecoveryBackend& backend, const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) {
|
||||
if (device_name.empty() || width == 0 || height == 0 || refresh_rate == 0) return false;
|
||||
if (!PreserveValidHDRSiblingOrClear(backend)) return false;
|
||||
|
||||
DWORD existing_width = 0;
|
||||
DWORD existing_height = 0;
|
||||
DWORD existing_refresh_rate = 0;
|
||||
std::wstring existing_device_name;
|
||||
if (ReadValidMarkedMode(backend, existing_device_name, existing_width, existing_height, existing_refresh_rate)) {
|
||||
// A valid marked original is already protecting a live or failed
|
||||
// operation. Reuse it only when this manager has the same original;
|
||||
// replacing it would lose the only restoration point.
|
||||
return existing_device_name == device_name && existing_width == width && existing_height == height &&
|
||||
existing_refresh_rate == refresh_rate;
|
||||
}
|
||||
|
||||
// Deactivate an incomplete old mode operation before replacing any
|
||||
// originals. A crash anywhere before the final write is therefore a
|
||||
// harmless pre-mutation prefix.
|
||||
if (!backend.ClearMarker(kRegModeChanged)) return false;
|
||||
|
||||
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegModeDeviceName, device_name) &&
|
||||
backend.WriteDWORD(kRegOriginalWidth, width) && backend.WriteDWORD(kRegOriginalHeight, height) &&
|
||||
backend.WriteDWORD(kRegOriginalRefreshRate, refresh_rate) && backend.WriteDWORD(kRegModeChanged, 1);
|
||||
}
|
||||
|
||||
bool DisplayModeManager::PrepareHDRRecovery(
|
||||
DisplayRecoveryBackend& backend, const std::wstring& device_name, bool enabled) {
|
||||
if (device_name.empty()) return false;
|
||||
if (!PreserveValidModeSiblingOrClear(backend)) return false;
|
||||
|
||||
DWORD existing_original = 0;
|
||||
std::wstring existing_device_name;
|
||||
if (ReadValidMarkedHDR(backend, existing_device_name, existing_original)) {
|
||||
return existing_device_name == device_name && existing_original == (enabled ? 1u : 0u);
|
||||
}
|
||||
|
||||
if (!backend.ClearMarker(kRegHDRChanged)) return false;
|
||||
|
||||
return backend.WriteDWORD(kRegVersion, kRecoveryVersion) && backend.WriteString(kRegHDRDeviceName, device_name) &&
|
||||
backend.WriteDWORD(kRegOriginalHDR, enabled ? 1 : 0) && backend.WriteDWORD(kRegHDRChanged, 1);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool PrepareModeRecoveryAtRegistry(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) {
|
||||
Win32DisplayRecoveryBackend backend;
|
||||
return DisplayModeManager::PrepareModeRecovery(backend, device_name, width, height, refresh_rate);
|
||||
}
|
||||
|
||||
bool PrepareHDRRecoveryAtRegistry(const std::wstring& device_name, bool enabled) {
|
||||
Win32DisplayRecoveryBackend backend;
|
||||
return DisplayModeManager::PrepareHDRRecovery(backend, device_name, enabled);
|
||||
}
|
||||
|
||||
bool CompleteRecoveryOperationAtRegistry(const wchar_t* marker) {
|
||||
Win32DisplayRecoveryBackend backend;
|
||||
return CompleteRecoveryOperation(backend, marker);
|
||||
}
|
||||
|
||||
bool RecoverRecord(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live) {
|
||||
if (!backend.RecordExists()) return false;
|
||||
|
||||
DWORD version = 0;
|
||||
const bool has_version = backend.ReadDWORD(kRegVersion, version);
|
||||
const wchar_t* mode_device_value_name = kRegModeDeviceName;
|
||||
const wchar_t* hdr_device_value_name = kRegHDRDeviceName;
|
||||
if (has_version) {
|
||||
if (version != kRecoveryVersion) {
|
||||
backend.DeleteRecord();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// The released layout had no Version and shared one DeviceName between
|
||||
// mode and HDR. Require that discriminator before interpreting any
|
||||
// versionless values as recovery evidence.
|
||||
std::wstring legacy_device_name;
|
||||
if (!backend.ReadString(kRegLegacyDeviceName, legacy_device_name)) {
|
||||
backend.DeleteRecord();
|
||||
return false;
|
||||
}
|
||||
mode_device_value_name = kRegLegacyDeviceName;
|
||||
hdr_device_value_name = kRegLegacyDeviceName;
|
||||
}
|
||||
|
||||
DWORD mode_marker = 0;
|
||||
const bool mode_marker_read = backend.ReadDWORD(kRegModeChanged, mode_marker);
|
||||
std::wstring mode_device_name;
|
||||
DWORD width = 0;
|
||||
DWORD height = 0;
|
||||
DWORD refresh_rate = 0;
|
||||
const bool mode_requested =
|
||||
mode_marker_read && mode_marker == 1 &&
|
||||
ReadValidModeValues(backend, mode_device_value_name, mode_device_name, width, height, refresh_rate);
|
||||
if (!mode_is_live && (!mode_marker_read || mode_marker > 1 || (mode_marker == 1 && !mode_requested))) {
|
||||
// Malformation in one operation does not erase a valid or live sibling.
|
||||
backend.ClearMarker(kRegModeChanged);
|
||||
}
|
||||
|
||||
DWORD hdr_marker = 0;
|
||||
const bool hdr_marker_read = backend.ReadDWORD(kRegHDRChanged, hdr_marker);
|
||||
std::wstring hdr_device_name;
|
||||
DWORD original_hdr = 0;
|
||||
const bool hdr_requested = hdr_marker_read && hdr_marker == 1 &&
|
||||
ReadValidHDRValues(backend, hdr_device_value_name, hdr_device_name, original_hdr);
|
||||
if (!hdr_is_live && (!hdr_marker_read || hdr_marker > 1 || (hdr_marker == 1 && !hdr_requested))) {
|
||||
backend.ClearMarker(kRegHDRChanged);
|
||||
}
|
||||
|
||||
const bool recover_mode = mode_requested && !mode_is_live;
|
||||
const bool recover_hdr = hdr_requested && !hdr_is_live;
|
||||
if (!recover_mode && !recover_hdr) {
|
||||
DeleteRecordIfNoMarkedOperations(backend);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool recovered = false;
|
||||
|
||||
// Restore refresh rate / resolution.
|
||||
if (mode_changed) {
|
||||
DWORD width = 0, height = 0, refresh = 0;
|
||||
ReadRegistryDWORD(kRegOriginalWidth, width);
|
||||
ReadRegistryDWORD(kRegOriginalHeight, height);
|
||||
ReadRegistryDWORD(kRegOriginalRefreshRate, refresh);
|
||||
|
||||
if (width > 0 && height > 0 && refresh > 0) {
|
||||
DEVMODEW dm = {};
|
||||
dm.dmSize = sizeof(dm);
|
||||
dm.dmPelsWidth = width;
|
||||
dm.dmPelsHeight = height;
|
||||
dm.dmDisplayFrequency = refresh;
|
||||
dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY;
|
||||
|
||||
LONG rc = ChangeDisplaySettingsExW(device_name.c_str(), &dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
if (rc == DISP_CHANGE_SUCCESSFUL) recovered = true;
|
||||
bool completed = true;
|
||||
if (recover_mode) {
|
||||
if (backend.IsDevicePresent(mode_device_name) &&
|
||||
backend.RestoreMode(mode_device_name, width, height, refresh_rate)) {
|
||||
// A failed marker clear leaves an idempotent restoration for a later pass.
|
||||
completed = CompleteRecoveryOperation(backend, kRegModeChanged) && completed;
|
||||
} else {
|
||||
completed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore HDR state.
|
||||
if (hdr_changed) {
|
||||
DWORD hdr_was_enabled = 0;
|
||||
ReadRegistryDWORD(kRegOriginalHDR, hdr_was_enabled);
|
||||
|
||||
auto target_id = GetDisplayTargetId(device_name);
|
||||
if (target_id) {
|
||||
// Save DEVMODEW before toggle.
|
||||
DEVMODEW pre_dm = {};
|
||||
pre_dm.dmSize = sizeof(pre_dm);
|
||||
EnumDisplaySettingsW(device_name.c_str(), ENUM_CURRENT_SETTINGS, &pre_dm);
|
||||
|
||||
LONG result = SetHDRStateForTarget(*target_id, hdr_was_enabled != 0);
|
||||
|
||||
if (result == ERROR_SUCCESS) {
|
||||
recovered = true;
|
||||
// Restore display mode after HDR toggle.
|
||||
if (pre_dm.dmDisplayFrequency != 0) {
|
||||
pre_dm.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_DISPLAYFLAGS;
|
||||
ChangeDisplaySettingsExW(device_name.c_str(), &pre_dm, nullptr, CDS_FULLSCREEN, nullptr);
|
||||
}
|
||||
}
|
||||
if (recover_hdr) {
|
||||
if (backend.IsDevicePresent(hdr_device_name) && backend.RestoreHDR(hdr_device_name, original_hdr != 0)) {
|
||||
completed = CompleteRecoveryOperation(backend, kRegHDRChanged) && completed;
|
||||
} else {
|
||||
completed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up registry regardless of success.
|
||||
RegDeleteKeyW(HKEY_CURRENT_USER, kRegistryPath);
|
||||
return recovered;
|
||||
return completed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool DisplayModeManager::RecoverIfNeeded() {
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
RecoveryRunGuard run;
|
||||
if (!run.acquired()) return false;
|
||||
Win32DisplayRecoveryBackend backend;
|
||||
return RecoverRecord(backend, g_live_mode_recovery_record, g_live_hdr_recovery_record);
|
||||
}
|
||||
|
||||
bool DisplayModeManager::RecoverIfNeeded(DisplayRecoveryBackend& backend) {
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
RecoveryRunGuard run;
|
||||
if (!run.acquired()) return false;
|
||||
return RecoverRecord(backend, false, false);
|
||||
}
|
||||
|
||||
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
|
||||
bool DisplayModeManager::CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode) {
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
return CompleteRecoveryOperation(backend, mode ? kRegModeChanged : kRegHDRChanged);
|
||||
}
|
||||
|
||||
bool DisplayModeManager::RecoverIfNeededForTesting(
|
||||
DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live) {
|
||||
std::lock_guard<std::recursive_mutex> transaction_lock(g_display_override_mutex);
|
||||
RecoveryRunGuard run;
|
||||
if (!run.acquired()) return false;
|
||||
return RecoverRecord(backend, mode_is_live, hdr_is_live);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace mpv
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -22,6 +21,24 @@ struct DisplayConfigId {
|
||||
UINT32 id;
|
||||
};
|
||||
|
||||
// Windows-runner-internal boundary for deterministic crash-recovery tests.
|
||||
// Production uses the Win32/registry implementation in display_mode_manager.cpp.
|
||||
class DisplayRecoveryBackend {
|
||||
public:
|
||||
virtual ~DisplayRecoveryBackend() = default;
|
||||
|
||||
virtual bool RecordExists() const = 0;
|
||||
virtual bool ReadDWORD(const wchar_t* value_name, DWORD& value) = 0;
|
||||
virtual bool ReadString(const wchar_t* value_name, std::wstring& value) = 0;
|
||||
virtual bool WriteDWORD(const wchar_t* value_name, DWORD value) = 0;
|
||||
virtual bool WriteString(const wchar_t* value_name, const std::wstring& value) = 0;
|
||||
virtual bool IsDevicePresent(const std::wstring& device_name) const = 0;
|
||||
virtual bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) = 0;
|
||||
virtual bool RestoreHDR(const std::wstring& device_name, bool enabled) = 0;
|
||||
virtual bool ClearMarker(const wchar_t* value_name) = 0;
|
||||
virtual bool DeleteRecord() = 0;
|
||||
};
|
||||
|
||||
// Manages Windows display mode switching (refresh rate, HDR) for video playback.
|
||||
// Pure Win32 utility — no mpv or Flutter dependency.
|
||||
//
|
||||
@@ -88,17 +105,27 @@ class DisplayModeManager {
|
||||
|
||||
// --- Crash recovery ---
|
||||
|
||||
// Write current override state to registry for crash recovery.
|
||||
void WriteRecoveryState();
|
||||
// Persist a complete original followed by its operation marker. These
|
||||
// runner-internal seams make the crash ordering deterministic in tests.
|
||||
static bool PrepareModeRecovery(
|
||||
DisplayRecoveryBackend& backend, const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate);
|
||||
static bool PrepareHDRRecovery(DisplayRecoveryBackend& backend, const std::wstring& device_name, bool enabled);
|
||||
|
||||
// Clear the recovery state from registry.
|
||||
void ClearRecoveryState();
|
||||
|
||||
// Check for and recover from a prior crash that left display settings changed.
|
||||
// Should be called early in app startup. Returns true if recovery was performed.
|
||||
static bool RecoverIfNeeded(HWND window);
|
||||
// Check for and recover from a prior crash that left display settings
|
||||
// changed. Successful operation markers are cleared independently. Failed
|
||||
// operations remain for the next startup or display-topology notification.
|
||||
static bool RecoverIfNeeded();
|
||||
static bool RecoverIfNeeded(DisplayRecoveryBackend& backend);
|
||||
#if defined(PLEZY_DISPLAY_MODE_MANAGER_TESTING)
|
||||
// Exercise persisted lifecycle exits and per-operation live ownership without
|
||||
// touching a real display or registry.
|
||||
static bool CompleteRecoveryOperationForTesting(DisplayRecoveryBackend& backend, bool mode);
|
||||
static bool RecoverIfNeededForTesting(DisplayRecoveryBackend& backend, bool mode_is_live, bool hdr_is_live);
|
||||
#endif
|
||||
|
||||
private:
|
||||
friend class Win32DisplayRecoveryBackend;
|
||||
|
||||
// Get the GDI device name for the monitor containing the window.
|
||||
static std::wstring GetMonitorDeviceName(HWND window);
|
||||
|
||||
@@ -115,13 +142,6 @@ class DisplayModeManager {
|
||||
// Toggle HDR via DisplayConfig (version-dispatched).
|
||||
static LONG SetHDRStateForTarget(const DisplayConfigId& target, bool enabled);
|
||||
|
||||
// Registry helpers for crash recovery.
|
||||
static bool WriteRegistryDWORD(const wchar_t* value_name, DWORD value);
|
||||
static bool WriteRegistryString(const wchar_t* value_name, const std::wstring& value);
|
||||
static bool ReadRegistryDWORD(const wchar_t* value_name, DWORD& value);
|
||||
static bool ReadRegistryString(const wchar_t* value_name, std::wstring& value);
|
||||
static bool DeleteRegistryValue(const wchar_t* value_name);
|
||||
|
||||
// Stored original mode for restoration.
|
||||
std::wstring original_device_name_;
|
||||
DEVMODEW original_devmode_ = {};
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#include "display_mode_manager.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace mpv {
|
||||
namespace {
|
||||
|
||||
constexpr wchar_t kVersion[] = L"Version";
|
||||
constexpr wchar_t kModeDeviceName[] = L"ModeDeviceName";
|
||||
constexpr wchar_t kLegacyDeviceName[] = L"DeviceName";
|
||||
constexpr wchar_t kHDRDeviceName[] = L"HDRDeviceName";
|
||||
constexpr wchar_t kOriginalRefreshRate[] = L"OriginalRefreshRate";
|
||||
constexpr wchar_t kOriginalWidth[] = L"OriginalWidth";
|
||||
constexpr wchar_t kOriginalHeight[] = L"OriginalHeight";
|
||||
constexpr wchar_t kOriginalHDR[] = L"OriginalHDREnabled";
|
||||
constexpr wchar_t kModeChanged[] = L"ModeChanged";
|
||||
constexpr wchar_t kHDRChanged[] = L"HDRChanged";
|
||||
constexpr wchar_t kModeDevice[] = L"\\\\.\\DISPLAY1";
|
||||
constexpr wchar_t kHDRDevice[] = L"\\\\.\\DISPLAY2";
|
||||
|
||||
void Check(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << "display_mode_manager_test: " << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRecoveryBackend final : public DisplayRecoveryBackend {
|
||||
public:
|
||||
std::map<std::wstring, DWORD> dwords;
|
||||
std::map<std::wstring, std::wstring> strings;
|
||||
std::map<std::wstring, bool> device_present = {{kModeDevice, true}, {kHDRDevice, true}};
|
||||
std::vector<std::wstring> events;
|
||||
std::wstring expected_mode_device = kModeDevice;
|
||||
std::wstring expected_hdr_device = kHDRDevice;
|
||||
bool mode_restore_succeeds = true;
|
||||
bool hdr_restore_succeeds = true;
|
||||
bool mode_marker_clear_succeeds = true;
|
||||
bool hdr_marker_clear_succeeds = true;
|
||||
bool delete_succeeds = true;
|
||||
bool final_mode_marker_write_succeeds = true;
|
||||
bool final_hdr_marker_write_succeeds = true;
|
||||
int delete_attempts = 0;
|
||||
|
||||
void SeedBoth() {
|
||||
dwords[kVersion] = 1;
|
||||
dwords[kModeChanged] = 1;
|
||||
dwords[kHDRChanged] = 1;
|
||||
dwords[kOriginalWidth] = 3840;
|
||||
dwords[kOriginalHeight] = 2160;
|
||||
dwords[kOriginalRefreshRate] = 60;
|
||||
dwords[kOriginalHDR] = 0;
|
||||
strings[kModeDeviceName] = kModeDevice;
|
||||
strings[kHDRDeviceName] = kHDRDevice;
|
||||
}
|
||||
|
||||
bool RecordExists() const override { return !dwords.empty() || !strings.empty(); }
|
||||
|
||||
bool ReadDWORD(const wchar_t* value_name, DWORD& value) override {
|
||||
const auto it = dwords.find(value_name);
|
||||
if (it == dwords.end()) return false;
|
||||
value = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadString(const wchar_t* value_name, std::wstring& value) override {
|
||||
const auto it = strings.find(value_name);
|
||||
if (it == strings.end()) return false;
|
||||
value = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WriteDWORD(const wchar_t* value_name, DWORD value) override {
|
||||
const std::wstring name(value_name);
|
||||
events.push_back(L"write:" + name + L"=" + std::to_wstring(value));
|
||||
if ((name == kModeChanged && value == 1 && !final_mode_marker_write_succeeds) ||
|
||||
(name == kHDRChanged && value == 1 && !final_hdr_marker_write_succeeds)) {
|
||||
return false;
|
||||
}
|
||||
dwords[name] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WriteString(const wchar_t* value_name, const std::wstring& value) override {
|
||||
const std::wstring name(value_name);
|
||||
events.push_back(L"write:" + name);
|
||||
strings[name] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsDevicePresent(const std::wstring& device_name) const override {
|
||||
const auto it = device_present.find(device_name);
|
||||
return it != device_present.end() && it->second;
|
||||
}
|
||||
|
||||
bool RestoreMode(const std::wstring& device_name, DWORD width, DWORD height, DWORD refresh_rate) override {
|
||||
Check(device_name == expected_mode_device, "mode restore must use its persisted display");
|
||||
Check(width == 3840 && height == 2160 && refresh_rate == 60, "mode restore must use persisted originals");
|
||||
events.push_back(L"restore:mode");
|
||||
return mode_restore_succeeds;
|
||||
}
|
||||
|
||||
bool RestoreHDR(const std::wstring& device_name, bool enabled) override {
|
||||
Check(device_name == expected_hdr_device, "HDR restore must use its independently persisted display");
|
||||
Check(!enabled, "HDR restore must use the persisted original state");
|
||||
events.push_back(L"restore:hdr");
|
||||
return hdr_restore_succeeds;
|
||||
}
|
||||
|
||||
bool ClearMarker(const wchar_t* value_name) override {
|
||||
const std::wstring name(value_name);
|
||||
events.push_back(L"clear:" + name);
|
||||
const bool succeeds = name == kModeChanged ? mode_marker_clear_succeeds : hdr_marker_clear_succeeds;
|
||||
if (succeeds) dwords[name] = 0;
|
||||
return succeeds;
|
||||
}
|
||||
|
||||
bool DeleteRecord() override {
|
||||
++delete_attempts;
|
||||
events.push_back(L"delete");
|
||||
if (!delete_succeeds) return false;
|
||||
dwords.clear();
|
||||
strings.clear();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
size_t EventIndex(const std::vector<std::wstring>& events, const std::wstring& event) {
|
||||
for (size_t index = 0; index < events.size(); ++index) {
|
||||
if (events[index] == event) return index;
|
||||
}
|
||||
return events.size();
|
||||
}
|
||||
|
||||
bool ApplyModeAfterPreparing(FakeRecoveryBackend& backend) {
|
||||
if (!DisplayModeManager::PrepareModeRecovery(backend, kModeDevice, 3840, 2160, 60)) {
|
||||
return false;
|
||||
}
|
||||
backend.events.push_back(L"os:mode");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ApplyHDRAfterPreparing(FakeRecoveryBackend& backend) {
|
||||
if (!DisplayModeManager::PrepareHDRRecovery(backend, kHDRDevice, false)) return false;
|
||||
backend.events.push_back(L"os:hdr");
|
||||
return true;
|
||||
}
|
||||
|
||||
void TestMarkersArePersistedBeforeMutation() {
|
||||
FakeRecoveryBackend mode;
|
||||
Check(ApplyModeAfterPreparing(mode), "a complete mode recovery record must admit the OS mutation");
|
||||
const size_t mode_marker = EventIndex(mode.events, L"write:ModeChanged=1");
|
||||
const size_t mode_os = EventIndex(mode.events, L"os:mode");
|
||||
Check(mode_marker < mode_os, "the mode marker must be durable before the OS mutation");
|
||||
Check(
|
||||
EventIndex(mode.events, L"write:ModeDeviceName") < mode_marker &&
|
||||
EventIndex(mode.events, L"write:OriginalWidth=3840") < mode_marker &&
|
||||
EventIndex(mode.events, L"write:OriginalHeight=2160") < mode_marker &&
|
||||
EventIndex(mode.events, L"write:OriginalRefreshRate=60") < mode_marker,
|
||||
"all mode originals must precede the operation marker");
|
||||
|
||||
FakeRecoveryBackend hdr;
|
||||
Check(ApplyHDRAfterPreparing(hdr), "a complete HDR recovery record must admit the OS mutation");
|
||||
const size_t hdr_marker = EventIndex(hdr.events, L"write:HDRChanged=1");
|
||||
Check(
|
||||
EventIndex(hdr.events, L"write:HDRDeviceName") < hdr_marker &&
|
||||
EventIndex(hdr.events, L"write:OriginalHDREnabled=0") < hdr_marker &&
|
||||
hdr_marker < EventIndex(hdr.events, L"os:hdr"),
|
||||
"the HDR original and marker must be durable before the OS mutation");
|
||||
|
||||
FakeRecoveryBackend failed_marker;
|
||||
failed_marker.final_mode_marker_write_succeeds = false;
|
||||
Check(
|
||||
!ApplyModeAfterPreparing(failed_marker),
|
||||
"an OS mutation must not run when its final recovery marker cannot be persisted");
|
||||
Check(
|
||||
EventIndex(failed_marker.events, L"os:mode") == failed_marker.events.size(),
|
||||
"a failed marker write must leave the display untouched");
|
||||
}
|
||||
|
||||
void TestMalformedRecordIsIgnored() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.dwords[kVersion] = 2;
|
||||
backend.strings[kLegacyDeviceName] = kModeDevice;
|
||||
|
||||
Check(!DisplayModeManager::RecoverIfNeeded(backend), "an unknown recovery version must be ignored");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") == backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||
"a malformed record must not reach display APIs");
|
||||
Check(backend.delete_attempts == 1 && !backend.RecordExists(), "a malformed record must be discarded");
|
||||
}
|
||||
|
||||
void TestValidModeSurvivesMalformedHDR() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.dwords[kOriginalHDR] = 2;
|
||||
|
||||
Check(
|
||||
DisplayModeManager::RecoverIfNeeded(backend),
|
||||
"malformed HDR evidence must not discard an independently valid mode restore");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||
"only the valid mode operation may reach a display API");
|
||||
Check(
|
||||
EventIndex(backend.events, L"clear:HDRChanged") < backend.events.size(),
|
||||
"the malformed HDR operation must be discarded independently");
|
||||
}
|
||||
|
||||
void TestValidHDRSurvivesMalformedMode() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.dwords.erase(kOriginalHeight);
|
||||
|
||||
Check(
|
||||
DisplayModeManager::RecoverIfNeeded(backend),
|
||||
"malformed mode evidence must not discard an independently valid HDR restore");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") == backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||
"only the valid HDR operation may reach a display API");
|
||||
Check(
|
||||
EventIndex(backend.events, L"clear:ModeChanged") < backend.events.size(),
|
||||
"the malformed mode operation must be discarded independently");
|
||||
}
|
||||
|
||||
void TestPreparationClearsMalformedSibling() {
|
||||
FakeRecoveryBackend mode;
|
||||
mode.SeedBoth();
|
||||
mode.dwords[kModeChanged] = 0;
|
||||
mode.dwords[kOriginalHDR] = 2;
|
||||
Check(ApplyModeAfterPreparing(mode), "a malformed HDR sibling must not block a new valid mode operation");
|
||||
Check(mode.dwords[kHDRChanged] == 0, "mode preparation must not preserve malformed HDR evidence");
|
||||
|
||||
FakeRecoveryBackend hdr;
|
||||
hdr.SeedBoth();
|
||||
hdr.dwords[kHDRChanged] = 0;
|
||||
hdr.dwords.erase(kOriginalHeight);
|
||||
Check(ApplyHDRAfterPreparing(hdr), "a malformed mode sibling must not block a new valid HDR operation");
|
||||
Check(hdr.dwords[kModeChanged] == 0, "HDR preparation must not preserve malformed mode evidence");
|
||||
}
|
||||
|
||||
void TestModeAndHDRRestoreIndependently() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.mode_restore_succeeds = false;
|
||||
|
||||
Check(!DisplayModeManager::RecoverIfNeeded(backend), "one failed restore must retain the record");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||
"mode failure must not prevent the independent HDR restore");
|
||||
Check(backend.dwords[kModeChanged] == 1, "the failed mode marker must remain set");
|
||||
Check(backend.dwords[kHDRChanged] == 0, "the successful HDR marker must be cleared");
|
||||
|
||||
backend.events.clear();
|
||||
backend.mode_restore_succeeds = true;
|
||||
Check(DisplayModeManager::RecoverIfNeeded(backend), "a later pass must finish the retained mode restore");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||
"a later pass must not repeat the completed HDR restore");
|
||||
}
|
||||
|
||||
void TestFailedRestoreRemainsForTopologyRetry() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.dwords[kHDRChanged] = 0;
|
||||
backend.device_present[kModeDevice] = false;
|
||||
|
||||
Check(!DisplayModeManager::RecoverIfNeeded(backend), "an absent display must retain its marked restore");
|
||||
Check(backend.dwords[kModeChanged] == 1, "an absent display must keep its operation marker");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") == backend.events.size(),
|
||||
"an absent display must not call its restore API");
|
||||
|
||||
backend.events.clear();
|
||||
backend.device_present[kModeDevice] = true;
|
||||
Check(
|
||||
DisplayModeManager::RecoverIfNeeded(backend), "a synchronous topology retry must restore a reconnected display");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
|
||||
"the topology retry must attempt the retained restore");
|
||||
}
|
||||
|
||||
void TestMarkerClearFailureRemainsRetryable() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.dwords[kHDRChanged] = 0;
|
||||
backend.mode_marker_clear_succeeds = false;
|
||||
|
||||
Check(!DisplayModeManager::RecoverIfNeeded(backend), "marker persistence is part of recovery completion");
|
||||
Check(backend.dwords[kModeChanged] == 1, "a failed marker clear must retain idempotent recovery evidence");
|
||||
|
||||
backend.events.clear();
|
||||
backend.mode_marker_clear_succeeds = true;
|
||||
Check(DisplayModeManager::RecoverIfNeeded(backend), "a later pass must retry after marker persistence failure");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
|
||||
"the retained marker must cause the restore to be retried");
|
||||
}
|
||||
|
||||
void TestLifecycleCleanupPreservesPersistedSibling() {
|
||||
FakeRecoveryBackend mode_completed;
|
||||
mode_completed.SeedBoth();
|
||||
Check(
|
||||
DisplayModeManager::CompleteRecoveryOperationForTesting(mode_completed, true),
|
||||
"successful mode cleanup must durably clear its own marker");
|
||||
Check(
|
||||
mode_completed.dwords[kModeChanged] == 0 && mode_completed.dwords[kHDRChanged] == 1,
|
||||
"mode cleanup must preserve a persisted HDR sibling even without local HDR ownership");
|
||||
Check(
|
||||
mode_completed.dwords[kOriginalHDR] == 0 && mode_completed.strings[kHDRDeviceName] == kHDRDevice &&
|
||||
mode_completed.delete_attempts == 0,
|
||||
"mode cleanup must retain the HDR original and avoid deleting its record");
|
||||
|
||||
FakeRecoveryBackend hdr_failed_apply;
|
||||
hdr_failed_apply.SeedBoth();
|
||||
Check(
|
||||
DisplayModeManager::CompleteRecoveryOperationForTesting(hdr_failed_apply, false),
|
||||
"failed HDR apply cleanup must durably clear its own marker");
|
||||
Check(
|
||||
hdr_failed_apply.dwords[kHDRChanged] == 0 && hdr_failed_apply.dwords[kModeChanged] == 1,
|
||||
"HDR cleanup must preserve a persisted mode sibling even without local mode ownership");
|
||||
Check(
|
||||
hdr_failed_apply.dwords[kOriginalWidth] == 3840 && hdr_failed_apply.strings[kModeDeviceName] == kModeDevice &&
|
||||
hdr_failed_apply.delete_attempts == 0,
|
||||
"HDR cleanup must retain the mode original and avoid deleting its record");
|
||||
}
|
||||
|
||||
void TestReleasedLiveOperationRecoversAfterReconnect() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.device_present[kModeDevice] = false;
|
||||
|
||||
Check(
|
||||
!DisplayModeManager::RecoverIfNeededForTesting(backend, true, true),
|
||||
"topology recovery must not take either genuinely live operation");
|
||||
Check(backend.events.empty(), "live operations must not reach restore or persistence APIs");
|
||||
|
||||
Check(
|
||||
!DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
|
||||
"a released operation must remain marked while its target is absent");
|
||||
Check(
|
||||
backend.dwords[kModeChanged] == 1 && backend.dwords[kHDRChanged] == 1,
|
||||
"an absent released mode and its live HDR sibling must both retain their markers");
|
||||
|
||||
backend.events.clear();
|
||||
backend.device_present[kModeDevice] = true;
|
||||
Check(
|
||||
DisplayModeManager::RecoverIfNeededForTesting(backend, false, true),
|
||||
"a topology retry must restore the released mode after reconnect");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") == backend.events.size(),
|
||||
"reconnect recovery must restore the released mode without stealing live HDR");
|
||||
Check(
|
||||
backend.dwords[kModeChanged] == 0 && backend.dwords[kHDRChanged] == 1 && backend.delete_attempts == 0,
|
||||
"reconnect recovery must preserve the genuinely live sibling record");
|
||||
}
|
||||
|
||||
void TestVersionlessModeRecoveryAndCleanup() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.dwords[kModeChanged] = 1;
|
||||
backend.dwords[kHDRChanged] = 0;
|
||||
backend.dwords[kOriginalWidth] = 3840;
|
||||
backend.dwords[kOriginalHeight] = 2160;
|
||||
backend.dwords[kOriginalRefreshRate] = 60;
|
||||
backend.strings[kLegacyDeviceName] = kModeDevice;
|
||||
|
||||
Check(DisplayModeManager::RecoverIfNeeded(backend), "the released versionless mode layout must be recovered");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size(),
|
||||
"versionless mode recovery must use the backend restore");
|
||||
Check(
|
||||
backend.delete_attempts == 1 && !backend.RecordExists(),
|
||||
"completed versionless mode recovery must clean the legacy DeviceName and record");
|
||||
}
|
||||
|
||||
void TestVersionlessHDRRecoveryAndCleanup() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.dwords[kModeChanged] = 0;
|
||||
backend.dwords[kHDRChanged] = 1;
|
||||
backend.dwords[kOriginalHDR] = 0;
|
||||
backend.strings[kLegacyDeviceName] = kHDRDevice;
|
||||
|
||||
Check(DisplayModeManager::RecoverIfNeeded(backend), "the released versionless HDR layout must be recovered");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||
"versionless HDR recovery must use the backend restore");
|
||||
Check(
|
||||
backend.delete_attempts == 1 && !backend.RecordExists(),
|
||||
"completed versionless HDR recovery must clean the legacy DeviceName and record");
|
||||
}
|
||||
|
||||
void TestVersionlessModeAndHDRUseSharedDevice() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.dwords[kModeChanged] = 1;
|
||||
backend.dwords[kHDRChanged] = 1;
|
||||
backend.dwords[kOriginalWidth] = 3840;
|
||||
backend.dwords[kOriginalHeight] = 2160;
|
||||
backend.dwords[kOriginalRefreshRate] = 60;
|
||||
backend.dwords[kOriginalHDR] = 0;
|
||||
backend.strings[kLegacyDeviceName] = kModeDevice;
|
||||
backend.expected_hdr_device = kModeDevice;
|
||||
|
||||
Check(
|
||||
DisplayModeManager::RecoverIfNeeded(backend),
|
||||
"both versionless operations must recover from their shared DeviceName");
|
||||
Check(
|
||||
EventIndex(backend.events, L"restore:mode") < backend.events.size() &&
|
||||
EventIndex(backend.events, L"restore:hdr") < backend.events.size(),
|
||||
"the released shared-device layout must restore mode and HDR independently");
|
||||
Check(
|
||||
backend.delete_attempts == 1 && !backend.RecordExists(),
|
||||
"shared versionless recovery must remove the legacy record after both markers clear");
|
||||
}
|
||||
|
||||
void TestCleanupFailureDoesNotBlockNewOverride() {
|
||||
FakeRecoveryBackend backend;
|
||||
backend.SeedBoth();
|
||||
backend.dwords[kHDRChanged] = 0;
|
||||
backend.delete_succeeds = false;
|
||||
|
||||
Check(
|
||||
DisplayModeManager::RecoverIfNeeded(backend),
|
||||
"successful restoration must complete even when stale-value deletion fails");
|
||||
Check(backend.dwords[kModeChanged] == 0, "the successful restore marker must be clear");
|
||||
Check(backend.delete_attempts == 1, "completed recovery must make one best-effort cleanup attempt");
|
||||
|
||||
backend.events.clear();
|
||||
Check(ApplyModeAfterPreparing(backend), "failed cleanup must not block persistence or admission of a fresh override");
|
||||
Check(
|
||||
backend.dwords[kModeChanged] == 1 &&
|
||||
EventIndex(backend.events, L"write:ModeChanged=1") < EventIndex(backend.events, L"os:mode"),
|
||||
"the fresh override must replace stale values with a pre-mutation marker");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mpv
|
||||
|
||||
int main() {
|
||||
mpv::TestMarkersArePersistedBeforeMutation();
|
||||
mpv::TestMalformedRecordIsIgnored();
|
||||
mpv::TestValidModeSurvivesMalformedHDR();
|
||||
mpv::TestValidHDRSurvivesMalformedMode();
|
||||
mpv::TestPreparationClearsMalformedSibling();
|
||||
mpv::TestModeAndHDRRestoreIndependently();
|
||||
mpv::TestFailedRestoreRemainsForTopologyRetry();
|
||||
mpv::TestMarkerClearFailureRemainsRetryable();
|
||||
mpv::TestLifecycleCleanupPreservesPersistedSibling();
|
||||
mpv::TestReleasedLiveOperationRecoversAfterReconnect();
|
||||
mpv::TestVersionlessModeRecoveryAndCleanup();
|
||||
mpv::TestVersionlessHDRRecoveryAndCleanup();
|
||||
mpv::TestVersionlessModeAndHDRUseSharedDevice();
|
||||
mpv::TestCleanupFailureDoesNotBlockNewOverride();
|
||||
std::cout << "display_mode_manager_test: PASS\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
#include "mpv_player.h"
|
||||
|
||||
#include <commctrl.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
#include "sanitize_utf8.h"
|
||||
|
||||
namespace mpv {
|
||||
|
||||
struct InnerWindowSubclassState {
|
||||
HWND hwnd = nullptr;
|
||||
std::atomic<HWND> forward_target{nullptr};
|
||||
std::atomic<bool> active{false};
|
||||
UINT_PTR subclass_id = 0;
|
||||
// Guarded by g_inner_subclasses_mutex.
|
||||
bool installed = false;
|
||||
// While true, the window thread may still remove this generation, so a
|
||||
// replacement must not adopt it.
|
||||
bool removal_pending = false;
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
|
||||
@@ -51,8 +66,8 @@ 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). Subclass the inner window
|
||||
// (legal within one process, even across threads) and forward mouse and pointer
|
||||
// entirely instead of routing it to a sibling). Use the common-controls
|
||||
// subclass chain with per-window reference data, and forward mouse/pointer
|
||||
// input to the Flutter view. Pointer messages must be sent synchronously:
|
||||
// Flutter calls GetPointerInfo while handling them, and Windows only retains
|
||||
// that data for the current or forwarded message.
|
||||
@@ -74,24 +89,37 @@ static_assert(IsFlutterPointerMessage(WM_POINTERUP));
|
||||
static_assert(IsFlutterPointerMessage(WM_POINTERLEAVE));
|
||||
static_assert(!IsFlutterPointerMessage(WM_MOUSEMOVE));
|
||||
|
||||
WNDPROC g_mpv_inner_original_proc = nullptr;
|
||||
HWND g_mpv_inner_hwnd = nullptr;
|
||||
HWND g_forward_target_view = nullptr;
|
||||
std::mutex g_inner_subclasses_mutex;
|
||||
std::unordered_map<HWND, std::shared_ptr<InnerWindowSubclassState>> g_inner_subclasses;
|
||||
std::atomic<uint64_t> g_next_inner_subclass_generation{1};
|
||||
|
||||
LRESULT CALLBACK MpvInnerSubclassProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
if (IsFlutterPointerMessage(message)) {
|
||||
HWND view = g_forward_target_view;
|
||||
if (view) {
|
||||
// WM_POINTER coordinates are already in screen space. SendMessage also
|
||||
// preserves the message association required by GetPointerInfo in the
|
||||
// Flutter view's window procedure.
|
||||
::SendMessageW(view, message, wparam, lparam);
|
||||
}
|
||||
std::shared_ptr<InnerWindowSubclassState> FindInnerSubclassState(HWND hwnd, DWORD_PTR reference_data) {
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(hwnd);
|
||||
if (it == g_inner_subclasses.end() || reinterpret_cast<DWORD_PTR>(it->second.get()) != reference_data) {
|
||||
return nullptr;
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK MpvInnerSubclassProc(
|
||||
HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR subclass_id, DWORD_PTR reference_data) {
|
||||
const auto state = FindInnerSubclassState(hwnd, reference_data);
|
||||
if (!state) {
|
||||
return ::DefSubclassProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
const bool active = state->active.load(std::memory_order_acquire);
|
||||
HWND view = active ? state->forward_target.load(std::memory_order_acquire) : nullptr;
|
||||
if (active && view && IsFlutterPointerMessage(message)) {
|
||||
// WM_POINTER coordinates are already in screen space. SendMessage also
|
||||
// preserves the message association required by GetPointerInfo in the
|
||||
// Flutter view's window procedure.
|
||||
::SendMessageW(view, message, wparam, lparam);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) {
|
||||
HWND view = g_forward_target_view;
|
||||
if (active && message >= WM_MOUSEFIRST && message <= WM_MOUSELAST) {
|
||||
if (view) {
|
||||
LPARAM forwarded = lparam;
|
||||
if (message != WM_MOUSEWHEEL && message != WM_MOUSEHWHEEL) {
|
||||
@@ -101,25 +129,287 @@ LRESULT CALLBACK MpvInnerSubclassProc(HWND hwnd, UINT message, WPARAM wparam, LP
|
||||
::MapWindowPoints(hwnd, view, &pt, 1);
|
||||
forwarded = MAKELPARAM(pt.x, pt.y);
|
||||
}
|
||||
::PostMessage(view, message, wparam, forwarded);
|
||||
::PostMessageW(view, message, wparam, forwarded);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return ::CallWindowProc(g_mpv_inner_original_proc, hwnd, message, wparam, lparam);
|
||||
|
||||
if (message == WM_NCDESTROY) {
|
||||
state->active.store(false, std::memory_order_release);
|
||||
state->forward_target.store(nullptr, std::memory_order_release);
|
||||
::RemoveWindowSubclass(hwnd, MpvInnerSubclassProc, subclass_id);
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(hwnd);
|
||||
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
||||
g_inner_subclasses.erase(it);
|
||||
}
|
||||
}
|
||||
return ::DefSubclassProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// Subclass mpv's lazily-created inner window if it exists and isn't yet
|
||||
// subclassed (or was recreated). Idempotent; callable from any thread in
|
||||
// this process.
|
||||
void EnsureMpvInnerSubclassed(HWND host) {
|
||||
if (!host) {
|
||||
constexpr UINT kSubclassOwnershipMessage = WM_APP + 0x0504;
|
||||
|
||||
enum class SubclassOwnershipActionPhase {
|
||||
kPending,
|
||||
kRunning,
|
||||
kCompleted,
|
||||
};
|
||||
|
||||
struct SubclassOwnershipAction {
|
||||
std::shared_ptr<InnerWindowSubclassState> state;
|
||||
bool install;
|
||||
std::mutex mutex;
|
||||
SubclassOwnershipActionPhase phase = SubclassOwnershipActionPhase::kPending;
|
||||
bool cancelled = false;
|
||||
bool success = false;
|
||||
};
|
||||
|
||||
std::mutex g_subclass_actions_mutex;
|
||||
std::unordered_map<UINT_PTR, std::shared_ptr<SubclassOwnershipAction>> g_subclass_actions;
|
||||
std::atomic<UINT_PTR> g_next_subclass_action{1};
|
||||
|
||||
void ForgetInnerSubclassState(const std::shared_ptr<InnerWindowSubclassState>& state) {
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(state->hwnd);
|
||||
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
||||
g_inner_subclasses.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
bool ApplySubclassOwnershipAction(const SubclassOwnershipAction& action) {
|
||||
if (action.install) {
|
||||
return ::SetWindowSubclass(
|
||||
action.state->hwnd, MpvInnerSubclassProc, action.state->subclass_id,
|
||||
reinterpret_cast<DWORD_PTR>(action.state.get())) != FALSE;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(action.state->hwnd);
|
||||
if (it == g_inner_subclasses.end() || it->second.get() != action.state.get()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool removed =
|
||||
::RemoveWindowSubclass(action.state->hwnd, MpvInnerSubclassProc, action.state->subclass_id) != FALSE;
|
||||
if (removed) {
|
||||
g_inner_subclasses.erase(it);
|
||||
} else {
|
||||
action.state->removal_pending = false;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
void ExecuteSubclassOwnershipAction(const std::shared_ptr<SubclassOwnershipAction>& action) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
if (action->phase != SubclassOwnershipActionPhase::kPending) return;
|
||||
if (action->cancelled) {
|
||||
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
||||
if (action->install) {
|
||||
ForgetInnerSubclassState(action->state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
action->phase = SubclassOwnershipActionPhase::kRunning;
|
||||
}
|
||||
|
||||
const bool applied = ApplySubclassOwnershipAction(*action);
|
||||
bool cancelled_install = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
cancelled_install = action->install && action->cancelled;
|
||||
if (!cancelled_install) {
|
||||
action->success = applied;
|
||||
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled_install) {
|
||||
if (action->install && !applied) {
|
||||
ForgetInnerSubclassState(action->state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
HWND inner = ::FindWindowExW(host, nullptr, nullptr, nullptr);
|
||||
if (inner && inner != g_mpv_inner_hwnd) {
|
||||
g_mpv_inner_hwnd = inner;
|
||||
g_mpv_inner_original_proc = reinterpret_cast<WNDPROC>(
|
||||
::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(MpvInnerSubclassProc)));
|
||||
|
||||
// A timeout can race an action that the window thread has already begun.
|
||||
// Remove a late install on that same thread before releasing the action's
|
||||
// shared ownership of the reference data used by the subclass callback.
|
||||
const bool detached = !applied || ::RemoveWindowSubclass(
|
||||
action->state->hwnd, MpvInnerSubclassProc, action->state->subclass_id) != FALSE;
|
||||
if (detached) {
|
||||
ForgetInnerSubclassState(action->state);
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
action->success = false;
|
||||
action->phase = SubclassOwnershipActionPhase::kCompleted;
|
||||
}
|
||||
|
||||
LRESULT CALLBACK SubclassOwnershipHook(int code, WPARAM wparam, LPARAM lparam) {
|
||||
if (code >= 0) {
|
||||
const auto* message = reinterpret_cast<const CWPSTRUCT*>(lparam);
|
||||
if (message && message->message == kSubclassOwnershipMessage) {
|
||||
std::shared_ptr<SubclassOwnershipAction> action;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
||||
const auto it = g_subclass_actions.find(static_cast<UINT_PTR>(message->wParam));
|
||||
if (it != g_subclass_actions.end() && it->second->state->hwnd == message->hwnd) {
|
||||
action = it->second;
|
||||
}
|
||||
}
|
||||
if (action) {
|
||||
ExecuteSubclassOwnershipAction(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ::CallNextHookEx(nullptr, code, wparam, lparam);
|
||||
}
|
||||
|
||||
bool RunSubclassOwnershipActionOnWindowThread(const std::shared_ptr<SubclassOwnershipAction>& action) {
|
||||
DWORD window_thread = ::GetWindowThreadProcessId(action->state->hwnd, nullptr);
|
||||
if (!window_thread) return false;
|
||||
if (window_thread == ::GetCurrentThreadId()) {
|
||||
ExecuteSubclassOwnershipAction(action);
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
return action->success;
|
||||
}
|
||||
|
||||
HHOOK hook = ::SetWindowsHookExW(WH_CALLWNDPROC, SubclassOwnershipHook, nullptr, window_thread);
|
||||
if (!hook) return false;
|
||||
|
||||
const UINT_PTR action_id = g_next_subclass_action.fetch_add(1, std::memory_order_relaxed);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
||||
g_subclass_actions[action_id] = action;
|
||||
}
|
||||
|
||||
DWORD_PTR message_result = 0;
|
||||
::SendMessageTimeoutW(
|
||||
action->state->hwnd, kSubclassOwnershipMessage, action_id, 0, SMTO_ABORTIFHUNG, 1000, &message_result);
|
||||
|
||||
bool success = false;
|
||||
{
|
||||
// Completion and cancellation use the same lock. If the callback won the
|
||||
// race, its acknowledged result is authoritative. Otherwise it observes
|
||||
// cancellation and cannot leave a late install referencing released data.
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
if (action->phase == SubclassOwnershipActionPhase::kCompleted) {
|
||||
success = action->success;
|
||||
} else {
|
||||
action->cancelled = true;
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_subclass_actions_mutex);
|
||||
const auto it = g_subclass_actions.find(action_id);
|
||||
if (it != g_subclass_actions.end() && it->second == action) {
|
||||
g_subclass_actions.erase(it);
|
||||
}
|
||||
}
|
||||
::UnhookWindowsHookEx(hook);
|
||||
return success;
|
||||
}
|
||||
|
||||
std::shared_ptr<InnerWindowSubclassState> InstallMpvInnerSubclass(HWND inner, HWND forward_target) {
|
||||
if (!inner || !forward_target) return nullptr;
|
||||
|
||||
std::shared_ptr<InnerWindowSubclassState> state;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto existing = g_inner_subclasses.find(inner);
|
||||
if (existing != g_inner_subclasses.end()) {
|
||||
const auto& retained = existing->second;
|
||||
if (!retained->installed || retained->active.load(std::memory_order_acquire) || retained->removal_pending) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// A timed-out detach that never reached the window thread leaves the
|
||||
// helper-chain entry installed. Adopt that exact generation rather than
|
||||
// stacking a duplicate subclass or retaining a permanently inert entry.
|
||||
retained->forward_target.store(forward_target, std::memory_order_release);
|
||||
retained->active.store(true, std::memory_order_release);
|
||||
return retained;
|
||||
}
|
||||
state = std::make_shared<InnerWindowSubclassState>();
|
||||
state->hwnd = inner;
|
||||
state->forward_target.store(forward_target, std::memory_order_relaxed);
|
||||
state->subclass_id = g_next_inner_subclass_generation.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
// Publish the state before installing the helper-chain entry. The
|
||||
// callback's reference data identifies this exact generation, so an old
|
||||
// callback can never resolve a replacement generation that reuses HWND.
|
||||
g_inner_subclasses[inner] = state;
|
||||
}
|
||||
|
||||
auto action = std::make_shared<SubclassOwnershipAction>();
|
||||
action->state = state;
|
||||
action->install = true;
|
||||
if (!RunSubclassOwnershipActionOnWindowThread(action)) {
|
||||
bool action_never_started = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
action_never_started = action->phase == SubclassOwnershipActionPhase::kPending;
|
||||
}
|
||||
if (action_never_started) {
|
||||
ForgetInnerSubclassState(state);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(inner);
|
||||
if (it == g_inner_subclasses.end() || it->second.get() != state.get()) {
|
||||
return nullptr;
|
||||
}
|
||||
state->installed = true;
|
||||
state->active.store(true, std::memory_order_release);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
void DetachMpvInnerSubclassState(const std::shared_ptr<InnerWindowSubclassState>& state) {
|
||||
if (!state) return;
|
||||
|
||||
// Invalidate forwarding before removing the helper-chain entry. A callback
|
||||
// already holding this generation can still call DefSubclassProc, but can
|
||||
// no longer target a replacement Flutter/player generation.
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(state->hwnd);
|
||||
if (it == g_inner_subclasses.end() || it->second.get() != state.get()) {
|
||||
return;
|
||||
}
|
||||
state->active.store(false, std::memory_order_release);
|
||||
state->forward_target.store(nullptr, std::memory_order_release);
|
||||
state->removal_pending = true;
|
||||
}
|
||||
|
||||
auto action = std::make_shared<SubclassOwnershipAction>();
|
||||
action->state = state;
|
||||
action->install = false;
|
||||
const bool detached = RunSubclassOwnershipActionOnWindowThread(action);
|
||||
if (!detached) {
|
||||
bool removal_not_applied = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(action->mutex);
|
||||
removal_not_applied = action->phase == SubclassOwnershipActionPhase::kPending ||
|
||||
(action->phase == SubclassOwnershipActionPhase::kCompleted && !action->success);
|
||||
}
|
||||
if (removal_not_applied) {
|
||||
std::lock_guard<std::mutex> lock(g_inner_subclasses_mutex);
|
||||
const auto it = g_inner_subclasses.find(state->hwnd);
|
||||
if (it != g_inner_subclasses.end() && it->second.get() == state.get()) {
|
||||
// RunSubclassOwnershipActionOnWindowThread has already unregistered
|
||||
// the cancelled action and hook. The installed entry is now stable
|
||||
// and can be reactivated by a replacement owner.
|
||||
state->removal_pending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!detached && !::IsWindow(state->hwnd)) {
|
||||
// A destroyed HWND has already discarded its subclass chain, so no
|
||||
// callback can retain the reference data even if dispatch was unavailable.
|
||||
ForgetInnerSubclassState(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +419,29 @@ MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
|
||||
|
||||
MpvPlayer::~MpvPlayer() { Dispose(); }
|
||||
|
||||
void MpvPlayer::EnsureMpvInnerSubclassed() {
|
||||
if (!hwnd_ || !forward_target_view_) return;
|
||||
|
||||
HWND inner = ::FindWindowExW(hwnd_, nullptr, nullptr, nullptr);
|
||||
if (!inner) return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(inner_subclass_mutex_);
|
||||
if (inner_subclass_ && inner_subclass_->hwnd == inner && inner_subclass_->active.load(std::memory_order_acquire)) {
|
||||
inner_subclass_->forward_target.store(forward_target_view_, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
DetachMpvInnerSubclassState(inner_subclass_);
|
||||
inner_subclass_.reset();
|
||||
inner_subclass_ = InstallMpvInnerSubclass(inner, forward_target_view_);
|
||||
}
|
||||
|
||||
void MpvPlayer::DetachMpvInnerSubclass() {
|
||||
std::lock_guard<std::mutex> lock(inner_subclass_mutex_);
|
||||
DetachMpvInnerSubclassState(inner_subclass_);
|
||||
inner_subclass_.reset();
|
||||
}
|
||||
|
||||
bool MpvPlayer::Initialize(HWND view) {
|
||||
if (mpv_) {
|
||||
return true; // Already initialized.
|
||||
@@ -166,7 +479,7 @@ bool MpvPlayer::Initialize(HWND view) {
|
||||
mpv_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
g_forward_target_view = view;
|
||||
forward_target_view_ = view;
|
||||
|
||||
// Set the wid option to embed mpv in our window.
|
||||
int64_t wid = reinterpret_cast<int64_t>(hwnd_);
|
||||
@@ -208,6 +521,8 @@ bool MpvPlayer::Initialize(HWND view) {
|
||||
int err = mpv_initialize(mpv_);
|
||||
if (err < 0) {
|
||||
if (hwnd_) {
|
||||
DetachMpvInnerSubclass();
|
||||
forward_target_view_ = nullptr;
|
||||
::DestroyWindow(hwnd_);
|
||||
hwnd_ = nullptr;
|
||||
}
|
||||
@@ -244,17 +559,15 @@ void MpvPlayer::Dispose() {
|
||||
auto* handle = mpv_;
|
||||
mpv_ = nullptr;
|
||||
|
||||
// The input subclass must stop referencing this player generation before
|
||||
// either the host HWND or the player object can be destroyed.
|
||||
DetachMpvInnerSubclass();
|
||||
forward_target_view_ = nullptr;
|
||||
|
||||
if (hwnd_) {
|
||||
::ShowWindow(hwnd_, SW_HIDE);
|
||||
::DestroyWindow(hwnd_);
|
||||
hwnd_ = nullptr;
|
||||
|
||||
// The subclassed inner window died with hwnd_; clear the forwarding
|
||||
// state. Only the owner of the window may do this: the audio-only core
|
||||
// (which never has an hwnd_) must not wipe the video instance's state.
|
||||
g_mpv_inner_hwnd = nullptr;
|
||||
g_mpv_inner_original_proc = nullptr;
|
||||
g_forward_target_view = nullptr;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
@@ -351,7 +664,7 @@ void MpvPlayer::SetRect(RECT rect, double device_pixel_ratio) {
|
||||
// mpv creates its inner window lazily on its own thread; subclass it (and
|
||||
// re-subclass if mpv ever recreates it) so mouse and pointer input over the
|
||||
// video is forwarded to the Flutter view.
|
||||
EnsureMpvInnerSubclassed(hwnd_);
|
||||
EnsureMpvInnerSubclassed();
|
||||
}
|
||||
|
||||
void MpvPlayer::SetVisible(bool visible) {
|
||||
@@ -388,11 +701,11 @@ void MpvPlayer::LogRecovery(const std::string& text) {
|
||||
SendEvent("log-message", data);
|
||||
}
|
||||
|
||||
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
|
||||
void MpvPlayer::TryAudioReload(const char* reason, int attempt, uint64_t request_generation) {
|
||||
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
||||
const std::string reason_copy = reason;
|
||||
CommandAsync({"ao-reload"}, [this, reason_copy, attempt](int error) {
|
||||
audio_recovery_.CompleteReload();
|
||||
CommandAsync({"ao-reload"}, [this, reason_copy, attempt, request_generation](int error) {
|
||||
audio_recovery_.CompleteReload(request_generation);
|
||||
LogRecovery(
|
||||
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
|
||||
", error=" + std::to_string(error) + ")");
|
||||
@@ -405,7 +718,7 @@ void MpvPlayer::MaybeRunAudioRecovery() {
|
||||
return;
|
||||
}
|
||||
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
|
||||
TryAudioReload(reason, action.attempt);
|
||||
TryAudioReload(reason, action.attempt, action.request_generation);
|
||||
if (action.exhausted) {
|
||||
LogRecovery("audio recovery budget exhausted; waiting for device list change");
|
||||
}
|
||||
@@ -557,7 +870,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
||||
// mpv's inner window exists by now (vo is configured); make sure the
|
||||
// DComp-mode input forwarding subclass is installed. SetRect alone can
|
||||
// miss it: the rect often settles before mpv creates the window.
|
||||
EnsureMpvInnerSubclassed(hwnd_);
|
||||
EnsureMpvInnerSubclassed();
|
||||
SendEvent("playback-restart");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "../../../shared/mpv/mpv_player_common.h"
|
||||
|
||||
namespace mpv {
|
||||
struct InnerWindowSubclassState;
|
||||
|
||||
// Wrapper for libmpv that handles initialization, commands, properties,
|
||||
// and event dispatching.
|
||||
@@ -96,12 +97,17 @@ class MpvPlayer {
|
||||
void SendPropertyChange(const char* name, mpv_node* data);
|
||||
void SendEvent(const std::string& name, const flutter::EncodableMap& data = {});
|
||||
void MaybeRunAudioRecovery();
|
||||
void TryAudioReload(const char* reason, int attempt);
|
||||
void TryAudioReload(const char* reason, int attempt, uint64_t request_generation);
|
||||
void LogRecovery(const std::string& text);
|
||||
void EnsureMpvInnerSubclassed();
|
||||
void DetachMpvInnerSubclass();
|
||||
|
||||
const bool audio_only_;
|
||||
mpv_handle* mpv_ = nullptr;
|
||||
HWND hwnd_ = nullptr;
|
||||
HWND forward_target_view_ = nullptr;
|
||||
std::mutex inner_subclass_mutex_;
|
||||
std::shared_ptr<InnerWindowSubclassState> inner_subclass_;
|
||||
|
||||
std::thread event_thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include "mpv_player.h"
|
||||
@@ -11,6 +14,25 @@ class MpvPlayerPropertyContractTestPeer {
|
||||
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 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 {
|
||||
@@ -22,6 +44,33 @@ void Check(bool condition, const char* message) {
|
||||
}
|
||||
}
|
||||
|
||||
std::atomic<int> g_forwarded_mouse_messages{0};
|
||||
std::atomic<int> g_forwarded_pointer_messages{0};
|
||||
constexpr UINT kBlockWindowThreadMessage = WM_APP + 0x0505;
|
||||
std::atomic<HANDLE> g_block_entered{nullptr};
|
||||
std::atomic<HANDLE> g_block_release{nullptr};
|
||||
|
||||
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);
|
||||
if (entered && release) {
|
||||
::SetEvent(entered);
|
||||
::WaitForSingleObject(release, INFINITE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_POINTERUPDATE) {
|
||||
g_forwarded_pointer_messages.fetch_add(1, std::memory_order_relaxed);
|
||||
return 0;
|
||||
}
|
||||
if (message == WM_MOUSEMOVE) {
|
||||
g_forwarded_mouse_messages.fetch_add(1, std::memory_order_relaxed);
|
||||
return 0;
|
||||
}
|
||||
return ::DefWindowProcW(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
void TestUnavailablePropertyWriteFails() {
|
||||
MpvPlayer player;
|
||||
int callback_count = 0;
|
||||
@@ -53,12 +102,271 @@ void TestPendingPropertyWriteFailsOnDispose() {
|
||||
Check(callback_count == 1, "repeated dispose must not complete a property write twice");
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(player);
|
||||
Check(
|
||||
reinterpret_cast<WNDPROC>(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original,
|
||||
"detach must restore the original procedure before window destruction");
|
||||
|
||||
::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");
|
||||
|
||||
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
|
||||
window_owner.join();
|
||||
g_forwarded_mouse_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);
|
||||
Check(block_entered && block_release, "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_forwarded_pointer_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);
|
||||
::SendMessageW(windows.inner, WM_POINTERUPDATE, 0, MAKELPARAM(12, 13));
|
||||
Check(
|
||||
g_forwarded_pointer_messages.load(std::memory_order_relaxed) == 1,
|
||||
"the adopted generation must resume pointer 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);
|
||||
::CloseHandle(block_entered);
|
||||
::CloseHandle(block_release);
|
||||
::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0);
|
||||
window_owner.join();
|
||||
g_forwarded_pointer_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();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mpv
|
||||
|
||||
int main() {
|
||||
mpv::TestUnavailablePropertyWriteFails();
|
||||
mpv::TestPendingPropertyWriteFailsOnDispose();
|
||||
mpv::TestPendingRequestTypesRemainDistinctOnDispose();
|
||||
mpv::TestInnerSubclassOwnershipIsSerializedAndDetached();
|
||||
mpv::TestTimedOutSubclassDetachCanBeAdopted();
|
||||
mpv::TestTimedOutSubclassInstallCannotOutliveItsState();
|
||||
std::cout << "mpv_player_property_contract_test: PASS\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef
|
||||
namespace mpv {
|
||||
|
||||
namespace {
|
||||
constexpr UINT kPlatformTaskMessage = WM_APP + 0x4D50;
|
||||
constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x4D51;
|
||||
constexpr UINT kPlatformTaskMessage = WM_APP + 0x04D0;
|
||||
constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x04D1;
|
||||
} // namespace
|
||||
|
||||
void MpvPlayerPlugin::RegisterWithRegistrar(
|
||||
@@ -67,6 +67,7 @@ MpvPlayerPlugin::MpvPlayerPlugin(
|
||||
}
|
||||
|
||||
MpvPlayerPlugin::~MpvPlayerPlugin() {
|
||||
player_generation_.fetch_add(1, std::memory_order_acq_rel);
|
||||
// Join the mpv event thread before draining: it enqueues platform tasks,
|
||||
// and platform_tasks_/platform_tasks_mutex_ are destroyed before player_
|
||||
// (reverse declaration order).
|
||||
@@ -180,12 +181,14 @@ void MpvPlayerPlugin::HandleMethodCall(
|
||||
// core is windowless, so it gets no view at all.
|
||||
HWND view = audio_only_ ? nullptr : GetChildWindow();
|
||||
|
||||
const uint64_t generation = player_generation_.fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||
player_ = std::make_unique<MpvPlayer>(audio_only_);
|
||||
bool success = player_->Initialize(view);
|
||||
|
||||
if (success) {
|
||||
// Set up event callback.
|
||||
player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); });
|
||||
player_->SetEventCallback(
|
||||
[this, generation](const flutter::EncodableValue& event) { SendEvent(generation, event); });
|
||||
|
||||
if (!audio_only_) {
|
||||
// Start hidden.
|
||||
@@ -197,6 +200,7 @@ void MpvPlayerPlugin::HandleMethodCall(
|
||||
result->Error("INIT_FAILED", "Failed to initialize MPV player");
|
||||
}
|
||||
} else if (method == "dispose") {
|
||||
player_generation_.fetch_add(1, std::memory_order_acq_rel);
|
||||
if (player_) {
|
||||
player_->Dispose();
|
||||
player_.reset();
|
||||
@@ -524,12 +528,13 @@ void MpvPlayerPlugin::HandleMethodCall(
|
||||
}
|
||||
}
|
||||
|
||||
void MpvPlayerPlugin::SendEvent(const flutter::EncodableValue& event) {
|
||||
void MpvPlayerPlugin::SendEvent(uint64_t player_generation, const flutter::EncodableValue& event) {
|
||||
// mpv events arrive on the mpv event thread; Flutter channel APIs are
|
||||
// platform-thread-only, so marshal onto the platform thread (the sink
|
||||
// null-check then also runs on the same thread as onListen/onCancel).
|
||||
PostToPlatformThread([this, event]() {
|
||||
if (event_sink_) {
|
||||
// platform-thread-only. Capture the player generation at receipt so queued
|
||||
// property/event callbacks from a disposed player cannot publish into its
|
||||
// replacement's stream.
|
||||
PostToPlatformThread([this, player_generation, event]() {
|
||||
if (player_generation_.load(std::memory_order_acquire) == player_generation && event_sink_) {
|
||||
event_sink_->Success(event);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -44,7 +46,7 @@ class MpvPlayerPlugin : public flutter::Plugin {
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
void SendEvent(const flutter::EncodableValue& event);
|
||||
void SendEvent(uint64_t player_generation, const flutter::EncodableValue& event);
|
||||
void PostToPlatformThread(std::function<void()> task);
|
||||
void DrainPlatformTasks();
|
||||
|
||||
@@ -64,6 +66,7 @@ class MpvPlayerPlugin : public flutter::Plugin {
|
||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>> event_sink_;
|
||||
|
||||
std::unique_ptr<MpvPlayer> player_;
|
||||
std::atomic<uint64_t> player_generation_{0};
|
||||
DisplayModeManager display_mode_manager_;
|
||||
std::optional<int32_t> proc_id_;
|
||||
std::mutex platform_tasks_mutex_;
|
||||
|
||||
Reference in New Issue
Block a user