Snapped windows on secondary displays can restore with the primary monitor's taskbar offset. Normalize the saved screen rect using its own monitor inset and reverse that conversion when checking restored placement. Keep missing-monitor fallback coordinates in the creation monitor's workspace. The Win32 API model changes four-cycle drift from plus or minus 192 pixels to zero. Single-monitor native fullscreen and quit-fullscreen/relaunch roundtrips passed; physical multi-monitor and mixed-DPI verification remains unavailable in the disconnected session.
354 lines
14 KiB
C++
354 lines
14 KiB
C++
#include "flutter_window.h"
|
|
|
|
#include <optional>
|
|
|
|
#include "flutter/generated_plugin_registrant.h"
|
|
#include "mpv/display_mode_manager.h"
|
|
#include "mpv/mpv_plugin.h"
|
|
|
|
static constexpr wchar_t kWindowPlacementKey[] = L"Software\\Plezy";
|
|
static constexpr wchar_t kWindowPlacementValue[] = L"WindowPlacement";
|
|
|
|
static UINT_PTR g_saveTimerId = 0;
|
|
static HWND g_mainHwnd = nullptr;
|
|
// When true, WM_WINDOWPOSCHANGED should not persist placement. Used while a
|
|
// native fullscreen toggle is in flux so we don't record the fullscreen rect
|
|
// as the user's last "normal" placement.
|
|
static bool g_suppressPlacementSave = false;
|
|
|
|
static void SaveWindowPlacement(HWND hwnd);
|
|
|
|
static void CALLBACK SaveTimerProc(HWND, UINT, UINT_PTR, DWORD) {
|
|
if (g_mainHwnd) SaveWindowPlacement(g_mainHwnd);
|
|
KillTimer(nullptr, g_saveTimerId);
|
|
g_saveTimerId = 0;
|
|
}
|
|
|
|
static void WriteWindowPlacement(const WINDOWPLACEMENT& wp) {
|
|
HKEY hKey;
|
|
if (RegCreateKeyExW(
|
|
HKEY_CURRENT_USER, kWindowPlacementKey, 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey,
|
|
nullptr) == ERROR_SUCCESS) {
|
|
RegSetValueExW(hKey, kWindowPlacementValue, 0, REG_BINARY, reinterpret_cast<const BYTE*>(&wp), sizeof(wp));
|
|
RegCloseKey(hKey);
|
|
}
|
|
}
|
|
|
|
// GetWindowPlacement with Aero Snap folded in. A snapped window is "arranged":
|
|
// Windows keeps the pre-snap rect in rcNormalPosition and still reports
|
|
// SW_SHOWNORMAL, so persisting the raw placement restores the window where it
|
|
// was *before* the snap. Substitute the on-screen rect instead; there is no
|
|
// API to re-enter the snapped state, so relaunch lands a normal window on the
|
|
// same rect. IsWindowArranged is exported by user32 since Windows 10 1903 but
|
|
// has no header/import-lib declaration; older builds fall through unchanged.
|
|
static bool QueryWindowPlacement(HWND hwnd, WINDOWPLACEMENT* wp) {
|
|
wp->length = sizeof(*wp);
|
|
if (!GetWindowPlacement(hwnd, wp)) return false;
|
|
|
|
using IsWindowArrangedFn = BOOL(WINAPI*)(HWND);
|
|
static const IsWindowArrangedFn is_window_arranged =
|
|
reinterpret_cast<IsWindowArrangedFn>(GetProcAddress(GetModuleHandleW(L"user32.dll"), "IsWindowArranged"));
|
|
if (!is_window_arranged || IsIconic(hwnd) || IsZoomed(hwnd) || !is_window_arranged(hwnd)) return true;
|
|
|
|
// rcNormalPosition uses the window monitor's workspace inset, not the
|
|
// primary work area's screen origin. LoadWindowPlacement reverses this.
|
|
RECT rect{};
|
|
if (!GetWindowRect(hwnd, &rect)) return true;
|
|
MONITORINFO mi{};
|
|
mi.cbSize = sizeof(mi);
|
|
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mi)) return true;
|
|
OffsetRect(&rect, mi.rcMonitor.left - mi.rcWork.left, mi.rcMonitor.top - mi.rcWork.top);
|
|
wp->rcNormalPosition = rect;
|
|
return true;
|
|
}
|
|
|
|
static void SaveWindowPlacement(HWND hwnd) {
|
|
// Never persist a hidden window: the exit path hides the window before its
|
|
// multi-second teardown, and a save landing in that gap would record
|
|
// SW_HIDE over the user's real show state.
|
|
if (!IsWindowVisible(hwnd)) return;
|
|
WINDOWPLACEMENT wp{};
|
|
if (!QueryWindowPlacement(hwnd, &wp)) return;
|
|
WriteWindowPlacement(wp);
|
|
}
|
|
|
|
// Loads the saved WINDOWPLACEMENT and applies it while keeping the window
|
|
// hidden; the first-frame callback in OnCreate performs the single show.
|
|
// Returns whether the window should be shown maximized.
|
|
static bool LoadWindowPlacement(HWND hwnd) {
|
|
HKEY hKey;
|
|
if (RegOpenKeyExW(HKEY_CURRENT_USER, kWindowPlacementKey, 0, KEY_READ, &hKey) != ERROR_SUCCESS) return false;
|
|
|
|
WINDOWPLACEMENT wp{};
|
|
wp.length = sizeof(wp);
|
|
DWORD size = sizeof(wp);
|
|
bool wasMaximized = false;
|
|
|
|
if (RegQueryValueExW(hKey, kWindowPlacementValue, nullptr, nullptr, reinterpret_cast<BYTE*>(&wp), &size) ==
|
|
ERROR_SUCCESS &&
|
|
size == sizeof(wp)) {
|
|
// A window minimized away from the maximized state stores SW_SHOWMINIMIZED
|
|
// plus WPF_RESTORETOMAXIMIZED; both spellings mean "maximized" on relaunch.
|
|
wasMaximized = wp.showCmd == SW_SHOWMAXIMIZED || (wp.flags & WPF_RESTORETOMAXIMIZED) != 0;
|
|
|
|
// The saved monitor may be gone (undocked laptop, powered-off TV).
|
|
// Resolve the saved rect's monitor before reversing its workspace inset.
|
|
// On a miss, keep the size but fall back to the default creation position
|
|
// so the window never restores invisible.
|
|
RECT screenRect = wp.rcNormalPosition;
|
|
MONITORINFO mi{};
|
|
mi.cbSize = sizeof(mi);
|
|
bool on_screen = false;
|
|
if (GetMonitorInfoW(MonitorFromRect(&screenRect, MONITOR_DEFAULTTONEAREST), &mi)) {
|
|
OffsetRect(&screenRect, mi.rcWork.left - mi.rcMonitor.left, mi.rcWork.top - mi.rcMonitor.top);
|
|
on_screen = MonitorFromRect(&screenRect, MONITOR_DEFAULTTONULL) != nullptr;
|
|
}
|
|
if (!on_screen) {
|
|
// GetWindowPlacement already expresses the creation rect in its own
|
|
// monitor's workspace, including when the default monitor has an inset.
|
|
WINDOWPLACEMENT current{};
|
|
current.length = sizeof(current);
|
|
if (!GetWindowPlacement(hwnd, ¤t)) {
|
|
RegCloseKey(hKey);
|
|
return false;
|
|
}
|
|
const LONG width = wp.rcNormalPosition.right - wp.rcNormalPosition.left;
|
|
const LONG height = wp.rcNormalPosition.bottom - wp.rcNormalPosition.top;
|
|
wp.rcNormalPosition.left = current.rcNormalPosition.left;
|
|
wp.rcNormalPosition.top = current.rcNormalPosition.top;
|
|
wp.rcNormalPosition.right = wp.rcNormalPosition.left + width;
|
|
wp.rcNormalPosition.bottom = wp.rcNormalPosition.top + height;
|
|
}
|
|
|
|
// Apply hidden. A visible showCmd here would display a blank window at
|
|
// the restored spot before Flutter has rendered anything.
|
|
wp.showCmd = SW_HIDE;
|
|
SetWindowPlacement(hwnd, &wp);
|
|
}
|
|
|
|
RegCloseKey(hKey);
|
|
return wasMaximized;
|
|
}
|
|
|
|
static void DebounceSaveWindowPlacement(HWND hwnd) {
|
|
g_mainHwnd = hwnd;
|
|
if (g_saveTimerId) KillTimer(nullptr, g_saveTimerId);
|
|
g_saveTimerId = SetTimer(nullptr, 0, 500, SaveTimerProc);
|
|
}
|
|
|
|
FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {}
|
|
|
|
FlutterWindow::~FlutterWindow() {}
|
|
|
|
bool FlutterWindow::OnCreate() {
|
|
if (!Win32Window::OnCreate()) {
|
|
return false;
|
|
}
|
|
|
|
RECT frame = GetClientArea();
|
|
|
|
// The size here must match the window dimensions to avoid unnecessary surface
|
|
// creation / destruction in the startup path.
|
|
flutter_controller_ =
|
|
std::make_unique<flutter::FlutterViewController>(frame.right - frame.left, frame.bottom - frame.top, project_);
|
|
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
|
|
return false;
|
|
}
|
|
RegisterPlugins(flutter_controller_->engine());
|
|
|
|
OutputDebugStringA("FlutterWindow: About to register MpvPlayerPlugin\n");
|
|
MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin"));
|
|
MpvAudioPlayerPluginRegisterWithRegistrar(
|
|
flutter_controller_->engine()->GetRegistrarForPlugin("MpvAudioPlayerPlugin"));
|
|
OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n");
|
|
|
|
RegisterWindowChannel();
|
|
|
|
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
|
|
|
HWND hwnd = GetHandle();
|
|
bool maximized = LoadWindowPlacement(hwnd);
|
|
|
|
flutter_controller_->engine()->SetNextFrameCallback(
|
|
[this, maximized]() { ::ShowWindow(this->GetHandle(), maximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL); });
|
|
|
|
// Flutter can complete the first frame before the "show window" callback is
|
|
// registered. The following call ensures a frame is pending to ensure the
|
|
// window is shown. It is a no-op if the first frame hasn't completed yet.
|
|
flutter_controller_->ForceRedraw();
|
|
|
|
return true;
|
|
}
|
|
|
|
void FlutterWindow::OnDestroy() {
|
|
// Cancel any pending save timer and save immediately
|
|
if (g_saveTimerId) {
|
|
KillTimer(nullptr, g_saveTimerId);
|
|
g_saveTimerId = 0;
|
|
}
|
|
// If still fullscreen at shutdown, persist the pre-fullscreen placement
|
|
// rather than the fullscreen rect so the next launch restores correctly.
|
|
if (is_fullscreen_ && placement_before_fullscreen_.length != 0) {
|
|
WriteWindowPlacement(placement_before_fullscreen_);
|
|
} else {
|
|
SaveWindowPlacement(GetHandle());
|
|
}
|
|
|
|
window_channel_ = nullptr;
|
|
|
|
if (flutter_controller_) {
|
|
flutter_controller_ = nullptr;
|
|
}
|
|
|
|
Win32Window::OnDestroy();
|
|
}
|
|
|
|
LRESULT
|
|
FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept {
|
|
// Give Flutter, including plugins, an opportunity to handle window messages.
|
|
if (flutter_controller_) {
|
|
std::optional<LRESULT> result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam);
|
|
if (result) {
|
|
return *result;
|
|
}
|
|
}
|
|
|
|
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;
|
|
case WM_WINDOWPOSCHANGED:
|
|
// Don't persist placement while fullscreen, mid-toggle, or hidden — the
|
|
// rect would overwrite the user's real window position or show state.
|
|
if (!is_fullscreen_ && !g_suppressPlacementSave && IsWindowVisible(hwnd)) {
|
|
DebounceSaveWindowPlacement(hwnd);
|
|
}
|
|
break;
|
|
}
|
|
|
|
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Native fullscreen (plezy/window method channel)
|
|
//
|
|
// Works around window_manager 0.5.1's multi-monitor fullscreen bug where the
|
|
// window ends up on the wrong monitor and renders only a fragment of the
|
|
// Flutter surface. We:
|
|
// 1) Resolve the target monitor by the current window's center point
|
|
// (robust across DPI and restore transitions).
|
|
// 2) Save WINDOWPLACEMENT + styles once.
|
|
// 3) Strip WS_OVERLAPPEDWINDOW and move+size in a single SetWindowPos so
|
|
// Flutter only relayouts once, at the final DPI/size.
|
|
// 4) On exit, restore styles and WINDOWPLACEMENT, re-maximizing if needed.
|
|
// ---------------------------------------------------------------------------
|
|
void FlutterWindow::RegisterWindowChannel() {
|
|
auto messenger = flutter_controller_->engine()->messenger();
|
|
window_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
|
messenger, "plezy/window", &flutter::StandardMethodCodec::GetInstance());
|
|
|
|
window_channel_->SetMethodCallHandler([this](
|
|
const flutter::MethodCall<flutter::EncodableValue>& call,
|
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
|
const std::string& name = call.method_name();
|
|
if (name == "setFullScreen") {
|
|
bool value = false;
|
|
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
|
|
auto it = args->find(flutter::EncodableValue("isFullScreen"));
|
|
if (it != args->end()) {
|
|
if (const bool* b = std::get_if<bool>(&it->second)) value = *b;
|
|
}
|
|
}
|
|
SetNativeFullScreen(value);
|
|
result->Success();
|
|
} else if (name == "isFullScreen") {
|
|
result->Success(flutter::EncodableValue(is_fullscreen_));
|
|
} else {
|
|
result->NotImplemented();
|
|
}
|
|
});
|
|
}
|
|
|
|
void FlutterWindow::NotifyFullScreenChanged() {
|
|
if (!window_channel_) return;
|
|
window_channel_->InvokeMethod("onFullScreenChanged", std::make_unique<flutter::EncodableValue>(is_fullscreen_));
|
|
}
|
|
|
|
void FlutterWindow::SetNativeFullScreen(bool fullscreen) {
|
|
HWND hwnd = GetHandle();
|
|
if (!hwnd) return;
|
|
if (fullscreen == is_fullscreen_) return;
|
|
|
|
g_suppressPlacementSave = true;
|
|
// Also cancel any pending save so a WM_WINDOWPOSCHANGED just before the
|
|
// toggle doesn't fire SaveTimerProc mid-transition.
|
|
if (g_saveTimerId) {
|
|
KillTimer(nullptr, g_saveTimerId);
|
|
g_saveTimerId = 0;
|
|
}
|
|
|
|
if (fullscreen) {
|
|
// Resolve target monitor first — if this fails we bail before mutating
|
|
// any saved state, so a subsequent exit call has nothing stale to act on.
|
|
RECT wr{};
|
|
::GetWindowRect(hwnd, &wr);
|
|
// Center point is stable across maximize/straddle cases where
|
|
// MonitorFromWindow would resolve to the neighbor monitor.
|
|
POINT center{(wr.left + wr.right) / 2, (wr.top + wr.bottom) / 2};
|
|
MONITORINFO mi{};
|
|
mi.cbSize = sizeof(mi);
|
|
if (!::GetMonitorInfoW(::MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST), &mi)) {
|
|
g_suppressPlacementSave = false;
|
|
return;
|
|
}
|
|
|
|
// Save pre-fullscreen state (showCmd inside the placement carries the
|
|
// maximize bit, so no separate flag is needed).
|
|
QueryWindowPlacement(hwnd, &placement_before_fullscreen_);
|
|
style_before_fullscreen_ = ::GetWindowLongPtr(hwnd, GWL_STYLE);
|
|
ex_style_before_fullscreen_ = ::GetWindowLongPtr(hwnd, GWL_EXSTYLE);
|
|
|
|
// Strip frame/caption. Stripping WS_OVERLAPPEDWINDOW alone is enough to
|
|
// make the following SetWindowPos use the given rect exactly — no need
|
|
// to ShowWindow(SW_SHOWNORMAL) first (would cause a second relayout).
|
|
::SetWindowLongPtr(hwnd, GWL_STYLE, style_before_fullscreen_ & ~WS_OVERLAPPEDWINDOW);
|
|
::SetWindowLongPtr(
|
|
hwnd, GWL_EXSTYLE,
|
|
ex_style_before_fullscreen_ & ~(WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE | WS_EX_CLIENTEDGE | WS_EX_STATICEDGE));
|
|
|
|
const RECT& r = mi.rcMonitor;
|
|
::SetWindowPos(
|
|
hwnd, HWND_TOP, r.left, r.top, r.right - r.left, r.bottom - r.top,
|
|
SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOACTIVATE);
|
|
|
|
is_fullscreen_ = true;
|
|
} else {
|
|
if (style_before_fullscreen_ != 0) {
|
|
::SetWindowLongPtr(hwnd, GWL_STYLE, style_before_fullscreen_);
|
|
::SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex_style_before_fullscreen_);
|
|
}
|
|
|
|
if (placement_before_fullscreen_.length == sizeof(WINDOWPLACEMENT)) {
|
|
WINDOWPLACEMENT wp = placement_before_fullscreen_;
|
|
if (wp.showCmd == SW_SHOWMINIMIZED) wp.showCmd = SW_SHOWNORMAL;
|
|
::SetWindowPlacement(hwnd, &wp);
|
|
}
|
|
|
|
// Force a frame refresh so restored chrome paints.
|
|
::SetWindowPos(
|
|
hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
|
|
|
|
is_fullscreen_ = false;
|
|
placement_before_fullscreen_ = {};
|
|
style_before_fullscreen_ = 0;
|
|
ex_style_before_fullscreen_ = 0;
|
|
}
|
|
|
|
g_suppressPlacementSave = false;
|
|
NotifyFullScreenChanged();
|
|
}
|