diff --git a/lib/main.dart b/lib/main.dart index 598b0452..81a44fd7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; import 'services/storage_service.dart'; import 'services/macos_window_service.dart'; +import 'services/native_window_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/settings_service.dart'; import 'utils/platform_detector.dart'; @@ -143,6 +144,9 @@ Future _bootstrapApp() async { // Configure macOS window with custom titlebar (depends on window manager) futures.add(MacOSWindowService.setupCustomTitlebar()); + // Hook Windows native fullscreen callback (no-op elsewhere). + NativeWindowService.initialize(); + // Initialize storage service futures.add(StorageService.getInstance()); diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 5ea00837..26a1c659 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:window_manager/window_manager.dart'; import 'macos_window_service.dart'; +import 'native_window_service.dart'; /// Global manager for tracking fullscreen state across the app class FullscreenStateManager extends ChangeNotifier with WindowListener { @@ -27,15 +28,22 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { /// Toggle fullscreen state, handling maximized-to-fullscreen transition on Windows/Linux Future toggleFullscreen() async { - final isCurrentlyFullscreen = await windowManager.isFullScreen(); - if (Platform.isMacOS) { + final isCurrentlyFullscreen = await windowManager.isFullScreen(); if (isCurrentlyFullscreen) { await MacOSWindowService.exitFullscreen(); } else { await MacOSWindowService.enterFullscreen(); } + } else if (Platform.isWindows) { + // Route through the native Win32 runner, which restores to the monitor + // the window is currently on (window_manager 0.5.1 picks the wrong one + // on multi-monitor setups — see issue #880). The native code also + // preserves maximized state internally, so no unmaximize dance here. + final isCurrentlyFullscreen = await NativeWindowService.isFullScreen(); + await NativeWindowService.setFullScreen(!isCurrentlyFullscreen); } else { + final isCurrentlyFullscreen = await windowManager.isFullScreen(); if (isCurrentlyFullscreen) { await windowManager.setFullScreen(false); if (_wasMaximized) { @@ -56,6 +64,8 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future exitFullscreen() async { if (Platform.isMacOS) { await MacOSWindowService.exitFullscreen(); + } else if (Platform.isWindows) { + await NativeWindowService.setFullScreen(false); } else { await windowManager.setFullScreen(false); if (_wasMaximized) { diff --git a/lib/services/native_window_service.dart b/lib/services/native_window_service.dart new file mode 100644 index 00000000..28b8d9e0 --- /dev/null +++ b/lib/services/native_window_service.dart @@ -0,0 +1,44 @@ +import 'dart:io' show Platform; +import 'package:flutter/services.dart'; +import 'fullscreen_state_manager.dart'; + +/// Windows-only native window wrapper. Exposes a monitor-aware fullscreen +/// implementation that lives in the Win32 runner (windows/runner/flutter_window.cpp) +/// as a workaround for window_manager's multi-monitor fullscreen bug +/// (edde746/plezy#880). +/// +/// No-op on non-Windows platforms — callers should keep using +/// `MacOSWindowService` on macOS and `window_manager` on Linux. +class NativeWindowService { + static const _channel = MethodChannel('plezy/window'); + static bool _initialized = false; + + /// Hook the native → Dart callback that keeps [FullscreenStateManager] in + /// sync with OS-driven fullscreen transitions. Safe to call more than once. + static void initialize() { + if (!Platform.isWindows || _initialized) return; + _channel.setMethodCallHandler(_handleMethodCall); + _initialized = true; + } + + static Future _handleMethodCall(MethodCall call) async { + if (call.method == 'onFullScreenChanged') { + final value = call.arguments; + if (value is bool) { + FullscreenStateManager().setFullscreen(value); + } + } + } + + /// Enter or exit native fullscreen on the current monitor. + static Future setFullScreen(bool isFullScreen) async { + if (!Platform.isWindows) return; + await _channel.invokeMethod('setFullScreen', {'isFullScreen': isFullScreen}); + } + + /// Query the native fullscreen state. Returns false off-Windows. + static Future isFullScreen() async { + if (!Platform.isWindows) return false; + return await _channel.invokeMethod('isFullScreen') ?? false; + } +} diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 38247513..de092184 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -12,6 +12,10 @@ static constexpr wchar_t kWindowPlacementValue[] = L"WindowPlacement"; // Debounce timer for saving window placement 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; // Forward declaration static void SaveWindowPlacement(HWND hwnd); @@ -23,12 +27,8 @@ static void CALLBACK SaveTimerProc(HWND, UINT, UINT_PTR, DWORD) { g_saveTimerId = 0; } -// Save WINDOWPLACEMENT to registry -static void SaveWindowPlacement(HWND hwnd) { - WINDOWPLACEMENT wp{}; - wp.length = sizeof(wp); - if (!GetWindowPlacement(hwnd, &wp)) return; - +// Write a WINDOWPLACEMENT struct directly to the registry. +static void WriteWindowPlacement(const WINDOWPLACEMENT& wp) { HKEY hKey; if (RegCreateKeyExW(HKEY_CURRENT_USER, kWindowPlacementKey, 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, @@ -39,6 +39,14 @@ static void SaveWindowPlacement(HWND hwnd) { } } +// Save the window's current WINDOWPLACEMENT to registry. +static void SaveWindowPlacement(HWND hwnd) { + WINDOWPLACEMENT wp{}; + wp.length = sizeof(wp); + if (!GetWindowPlacement(hwnd, &wp)) return; + WriteWindowPlacement(wp); +} + // Load and apply WINDOWPLACEMENT from registry // Returns whether the window should be maximized static bool LoadWindowPlacement(HWND hwnd) { @@ -100,6 +108,8 @@ bool FlutterWindow::OnCreate() { flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin")); OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n"); + RegisterWindowChannel(); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); // Load saved window placement before showing @@ -124,7 +134,15 @@ void FlutterWindow::OnDestroy() { KillTimer(nullptr, g_saveTimerId); g_saveTimerId = 0; } - SaveWindowPlacement(GetHandle()); + // 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; @@ -152,9 +170,144 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, flutter_controller_->engine()->ReloadSystemFonts(); break; case WM_WINDOWPOSCHANGED: - DebounceSaveWindowPlacement(hwnd); + // Don't persist placement while in fullscreen or mid-toggle — the rect + // would overwrite the user's real window position. + if (!is_fullscreen_ && !g_suppressPlacementSave) { + 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>( + messenger, "plezy/window", + &flutter::StandardMethodCodec::GetInstance()); + + window_channel_->SetMethodCallHandler( + [this](const flutter::MethodCall& call, + std::unique_ptr> + result) { + const std::string& name = call.method_name(); + if (name == "setFullScreen") { + bool value = false; + if (const auto* args = + std::get_if(call.arguments())) { + auto it = args->find(flutter::EncodableValue("isFullScreen")); + if (it != args->end()) { + if (const bool* b = std::get_if(&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(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). + placement_before_fullscreen_.length = sizeof(WINDOWPLACEMENT); + ::GetWindowPlacement(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(); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 6da0652f..56aef863 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include @@ -28,6 +30,21 @@ class FlutterWindow : public Win32Window { // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; + + // Method channel exposing window controls to Dart (plezy/window). + std::unique_ptr> + window_channel_; + + // Fullscreen state tracking for monitor-aware native fullscreen. + // Maximize state lives inside `placement_before_fullscreen_.showCmd`. + bool is_fullscreen_ = false; + WINDOWPLACEMENT placement_before_fullscreen_{}; + LONG_PTR style_before_fullscreen_ = 0; + LONG_PTR ex_style_before_fullscreen_ = 0; + + void RegisterWindowChannel(); + void SetNativeFullScreen(bool fullscreen); + void NotifyFullScreenChanged(); }; #endif // RUNNER_FLUTTER_WINDOW_H_