Extend dual-screen presenter to Windows and Linux

Bring the second-window (beamer) presenter mode to all desktop platforms,
not just macOS:

- Implement the native window_coverScreen / window_close methods for the
  vendored desktop_multi_window plugin on Windows (borderless popup over
  the presentation monitor) and Linux.
- Register the app's plugins for sub-windows in the Windows and Linux
  runners, so video/image rendering works in the audience window there too.
- Gate dual-screen mode through a testable shouldUseDualScreen() helper
  (any desktop platform with >= 2 displays) and cover it with tests.

flutter analyze is clean and all presenter tests pass. Runtime two-screen
behaviour still needs verification on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-06 22:03:56 +02:00
parent 2aca44365a
commit ffcda70966
6 changed files with 225 additions and 6 deletions

View file

@ -42,8 +42,8 @@ class FullscreenPresenter extends StatefulWidget {
}); });
/// Entry point used by the app: pick dual-screen mode when a second display is /// Entry point used by the app: pick dual-screen mode when a second display is
/// available (macOS), otherwise the single-window presenter. Any failure to /// available on desktop, otherwise the single-window presenter. Any failure
/// open the second window falls back to single-window mode. /// to open the second window falls back to single-window mode.
static Future<void> present( static Future<void> present(
BuildContext context, { BuildContext context, {
required List<Slide> slides, required List<Slide> slides,
@ -52,15 +52,21 @@ class FullscreenPresenter extends StatefulWidget {
required int initialIndex, required int initialIndex,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
}) async { }) async {
var dual = false; var displayCount = 0;
if (Platform.isMacOS) { if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
try { try {
final displays = await screenRetriever.getAllDisplays(); final displays = await screenRetriever.getAllDisplays();
dual = displays.length >= 2; displayCount = displays.length;
} catch (_) { } catch (_) {
dual = false; displayCount = 0;
} }
} }
final dual = shouldUseDualScreen(
isMacOS: Platform.isMacOS,
isWindows: Platform.isWindows,
isLinux: Platform.isLinux,
displayCount: displayCount,
);
if (!context.mounted) return; if (!context.mounted) return;
if (dual) { if (dual) {
await showDualScreen( await showDualScreen(
@ -204,6 +210,16 @@ class FullscreenPresenter extends StatefulWidget {
State<FullscreenPresenter> createState() => _FullscreenPresenterState(); State<FullscreenPresenter> createState() => _FullscreenPresenterState();
} }
@visibleForTesting
bool shouldUseDualScreen({
required bool isMacOS,
required bool isWindows,
required bool isLinux,
required int displayCount,
}) {
return (isMacOS || isWindows || isLinux) && displayCount >= 2;
}
Future<bool> _wakeLockEnabled() async { Future<bool> _wakeLockEnabled() async {
try { try {
return await WakelockPlus.enabled; return await WakelockPlus.enabled;

View file

@ -5,6 +5,7 @@
#include <gdk/gdkx.h> #include <gdk/gdkx.h>
#endif #endif
#include "desktop_multi_window/desktop_multi_window_plugin.h"
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
struct _MyApplication { struct _MyApplication {
@ -89,6 +90,8 @@ static void my_application_activate(GApplication* application) {
gtk_widget_realize(GTK_WIDGET(view)); gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view));
desktop_multi_window_plugin_set_window_created_callback(
[](FlPluginRegistry* registry) { fl_register_plugins(registry); });
gtk_widget_grab_focus(GTK_WIDGET(view)); gtk_widget_grab_focus(GTK_WIDGET(view));
} }

View file

@ -24,6 +24,57 @@ void main() {
Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']), Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']),
]; ];
test('dual-screen mode is available on every desktop platform', () {
expect(
shouldUseDualScreen(
isMacOS: true,
isWindows: false,
isLinux: false,
displayCount: 2,
),
isTrue,
);
expect(
shouldUseDualScreen(
isMacOS: false,
isWindows: true,
isLinux: false,
displayCount: 2,
),
isTrue,
);
expect(
shouldUseDualScreen(
isMacOS: false,
isWindows: false,
isLinux: true,
displayCount: 2,
),
isTrue,
);
});
test('dual-screen mode requires a desktop platform and two displays', () {
expect(
shouldUseDualScreen(
isMacOS: true,
isWindows: false,
isLinux: false,
displayCount: 1,
),
isFalse,
);
expect(
shouldUseDualScreen(
isMacOS: false,
isWindows: false,
isLinux: false,
displayCount: 2,
),
isFalse,
);
});
testWidgets('starts in audience view without presenter chrome', ( testWidgets('starts in audience view without presenter chrome', (
tester, tester,
) async { ) async {

View file

@ -1,7 +1,32 @@
#include "flutter_window.h" #include "flutter_window.h"
#include <cstring>
#include <iostream> #include <iostream>
namespace {
bool ReadExternalArgument(FlValue* arguments) {
if (arguments == nullptr ||
fl_value_get_type(arguments) != FL_VALUE_TYPE_MAP) {
return true;
}
FlValue* external = fl_value_lookup_string(arguments, "external");
if (external == nullptr ||
fl_value_get_type(external) != FL_VALUE_TYPE_BOOL) {
return true;
}
return fl_value_get_bool(external);
}
gboolean CloseWindowOnIdle(gpointer data) {
GtkWidget* window = GTK_WIDGET(data);
gtk_widget_destroy(window);
g_object_unref(window);
return G_SOURCE_REMOVE;
}
} // namespace
FlutterWindow::FlutterWindow(const std::string& id, FlutterWindow::FlutterWindow(const std::string& id,
const std::string& argument, const std::string& argument,
GtkWidget* window) GtkWidget* window)
@ -42,6 +67,49 @@ void FlutterWindow::HandleWindowMethod(const gchar* method,
} else if (strcmp(method, "window_hide") == 0) { } else if (strcmp(method, "window_hide") == 0) {
Hide(); Hide();
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (strcmp(method, "window_close") == 0) {
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
if (window_) {
g_idle_add(CloseWindowOnIdle, g_object_ref(window_));
}
} else if (strcmp(method, "window_coverScreen") == 0) {
if (!window_) {
response = FL_METHOD_RESPONSE(
fl_method_error_response_new("-1", "window is not available",
nullptr));
} else {
GtkWindow* window = GTK_WINDOW(window_);
GdkScreen* screen = gtk_window_get_screen(window);
GdkWindow* gdk_window = gtk_widget_get_window(window_);
const gint monitor_count = gdk_screen_get_n_monitors(screen);
gint current_monitor = gdk_window
? gdk_screen_get_monitor_at_window(screen,
gdk_window)
: gdk_screen_get_primary_monitor(screen);
if (current_monitor < 0) {
current_monitor = 0;
}
gint target_monitor = current_monitor;
if (ReadExternalArgument(arguments) && monitor_count > 1) {
for (gint i = 0; i < monitor_count; ++i) {
if (i != current_monitor) {
target_monitor = i;
break;
}
}
}
GdkRectangle bounds;
gdk_screen_get_monitor_geometry(screen, target_monitor, &bounds);
gtk_window_unfullscreen(window);
gtk_window_set_decorated(window, FALSE);
gtk_window_move(window, bounds.x, bounds.y);
gtk_window_resize(window, bounds.width, bounds.height);
gtk_window_fullscreen_on_monitor(window, screen, target_monitor);
gtk_widget_show(window_);
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
} else { } else {
g_autofree gchar* error_msg = g_strdup_printf("unknown method: %s", method); g_autofree gchar* error_msg = g_strdup_printf("unknown method: %s", method);
response = FL_METHOD_RESPONSE( response = FL_METHOD_RESPONSE(

View file

@ -8,6 +8,42 @@
#include <memory> #include <memory>
#include <string> #include <string>
namespace {
struct MonitorSearch {
HMONITOR current = nullptr;
HMONITOR external = nullptr;
HMONITOR fallback = nullptr;
};
inline BOOL CALLBACK FindPresentationMonitor(HMONITOR monitor,
HDC,
LPRECT,
LPARAM data) {
auto* search = reinterpret_cast<MonitorSearch*>(data);
if (!search->fallback) {
search->fallback = monitor;
}
if (monitor != search->current && !search->external) {
search->external = monitor;
}
return TRUE;
}
inline bool ReadExternalArgument(const flutter::EncodableMap* arguments) {
if (!arguments) {
return true;
}
const auto it = arguments->find(flutter::EncodableValue("external"));
if (it == arguments->end()) {
return true;
}
const auto* external = std::get_if<bool>(&it->second);
return external ? *external : true;
}
} // namespace
class FlutterWindowWrapper { class FlutterWindowWrapper {
public: public:
FlutterWindowWrapper(const std::string& window_id, FlutterWindowWrapper(const std::string& window_id,
@ -51,6 +87,45 @@ class FlutterWindowWrapper {
::ShowWindow(hwnd_, SW_HIDE); ::ShowWindow(hwnd_, SW_HIDE);
} }
result->Success(); result->Success();
} else if (method == "window_close") {
result->Success();
if (hwnd_) {
::PostMessage(hwnd_, WM_CLOSE, 0, 0);
}
} else if (method == "window_coverScreen") {
if (!hwnd_) {
result->Error("-1", "window is not available");
return;
}
MonitorSearch search;
search.current = ::MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST);
::EnumDisplayMonitors(
nullptr, nullptr, FindPresentationMonitor,
reinterpret_cast<LPARAM>(&search));
HMONITOR target = search.current;
if (ReadExternalArgument(arguments) && search.external) {
target = search.external;
} else if (!target) {
target = search.fallback;
}
MONITORINFO monitor_info{sizeof(MONITORINFO)};
if (!target || !::GetMonitorInfo(target, &monitor_info)) {
result->Error("-1", "unable to find a display");
return;
}
const RECT bounds = monitor_info.rcMonitor;
::SetWindowLongPtr(hwnd_, GWL_STYLE, WS_POPUP | WS_VISIBLE);
::SetWindowLongPtr(hwnd_, GWL_EXSTYLE,
::GetWindowLongPtr(hwnd_, GWL_EXSTYLE) &
~WS_EX_WINDOWEDGE);
::SetWindowPos(hwnd_, HWND_TOP, bounds.left, bounds.top,
bounds.right - bounds.left, bounds.bottom - bounds.top,
SWP_FRAMECHANGED | SWP_SHOWWINDOW);
result->Success();
} else { } else {
result->Error("-1", "unknown method: " + method); result->Error("-1", "unknown method: " + method);
} }

View file

@ -2,6 +2,7 @@
#include <optional> #include <optional>
#include "desktop_multi_window/desktop_multi_window_plugin.h"
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project) FlutterWindow::FlutterWindow(const flutter::DartProject& project)
@ -25,6 +26,11 @@ bool FlutterWindow::OnCreate() {
return false; return false;
} }
RegisterPlugins(flutter_controller_->engine()); RegisterPlugins(flutter_controller_->engine());
DesktopMultiWindowSetWindowCreatedCallback([](void* controller) {
auto* flutter_view_controller =
reinterpret_cast<flutter::FlutterViewController*>(controller);
RegisterPlugins(flutter_view_controller->engine());
});
SetChildContent(flutter_controller_->view()->GetNativeWindow()); SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() { flutter_controller_->engine()->SetNextFrameCallback([&]() {