diff --git a/flutter/assets/display_switcher.svg b/flutter/assets/display_switcher.svg
new file mode 100644
index 000000000..4f5b4bb58
--- /dev/null
+++ b/flutter/assets/display_switcher.svg
@@ -0,0 +1,7 @@
+
diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart
index 8caf6ee11..69f4be59e 100644
--- a/flutter/lib/consts.dart
+++ b/flutter/lib/consts.dart
@@ -174,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
+const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar";
+const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options
diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart
index d1d620014..ef5b75d36 100644
--- a/flutter/lib/desktop/pages/desktop_setting_page.dart
+++ b/flutter/lib/desktop/pages/desktop_setting_page.dart
@@ -407,6 +407,7 @@ class _GeneralState extends State<_General> {
final RxBool serviceStop =
isWeb ? RxBool(false) : Get.find(tag: 'stop-service');
RxBool serviceBtnEnabled = true.obs;
+ final GlobalKey _minToolbarOptionKey = GlobalKey();
@override
Widget build(BuildContext context) {
@@ -605,6 +606,47 @@ class _GeneralState extends State<_General> {
},
));
}
+ children.add(_OptionCheckBox(
+ context,
+ 'Show monitor switch button on the main toolbar',
+ kOptionAllowMonitorSwitchMainToolbar,
+ isServer: false,
+ update: (enabled) async {
+ if (!enabled) {
+ await mainSetLocalBoolOption(
+ kOptionAllowMonitorSwitchMinToolbar, false);
+ }
+ if (mounted) setState(() {});
+ reloadAllWindows();
+ if (enabled) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final ctx = _minToolbarOptionKey.currentContext;
+ if (ctx != null) {
+ Scrollable.ensureVisible(
+ ctx,
+ alignment: 0.5,
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeInOut,
+ );
+ }
+ });
+ }
+ },
+ ));
+ if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
+ children.add(KeyedSubtree(
+ key: _minToolbarOptionKey,
+ child: _OptionCheckBox(
+ context,
+ 'Show on the minimized toolbar',
+ kOptionAllowMonitorSwitchMinToolbar,
+ isServer: false,
+ update: (_) {
+ reloadAllWindows();
+ },
+ ).marginOnly(left: _kCheckBoxLeftMargin * 3),
+ ));
+ }
return _Card(title: 'Other', children: children);
}
diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart
index 686120be5..34879e5cb 100644
--- a/flutter/lib/desktop/widgets/remote_toolbar.dart
+++ b/flutter/lib/desktop/widgets/remote_toolbar.dart
@@ -779,6 +779,7 @@ class _RemoteToolbarState extends State {
borderRadius: borderRadius,
child: _DraggableShowHide(
id: widget.id,
+ ffi: widget.ffi,
sessionId: widget.ffi.sessionId,
dragging: _dragging,
fraction: _fraction,
@@ -805,6 +806,17 @@ class _RemoteToolbarState extends State {
BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
final List toolbarItems = [];
toolbarItems.add(_PinMenu(state: widget.state));
+ toolbarItems.add(Obx(() {
+ final privacyModeState = PrivacyModeState.find(widget.id);
+ if ((privacyModeState.isEmpty ||
+ allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
+ pi.displaysCount.value > 1 &&
+ mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
+ return _MainMonitorSwitchButton(id: widget.id, ffi: widget.ffi);
+ } else {
+ return const Offstage();
+ }
+ }));
if (!isWebDesktop) {
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
}
@@ -965,6 +977,88 @@ class _MobileActionMenu extends StatelessWidget {
}
}
+class _MonitorCycle {
+ final String id;
+ final FFI ffi;
+ const _MonitorCycle(this.id, this.ffi);
+
+ PeerInfo get _pi => ffi.ffiModel.pi;
+ int get total => _pi.displays.length;
+ int get _current => CurrentDisplayState.find(id).value;
+ bool get _inRange => _current >= 0 && _current < total;
+
+ String get label => _inRange ? '${_current + 1}' : '*';
+ String get tooltip => '${translate('Switch display')} ($label/$total)';
+
+ void next() {
+ final t = total;
+ if (t < 2) return;
+ final from = _inRange ? _current : -1;
+ final target = (from + 1) % t;
+ final isChooseDisplayToOpenInNewWindow = _pi.isSupportMultiDisplay &&
+ bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) ==
+ 'Y';
+ if (isChooseDisplayToOpenInNewWindow) {
+ openMonitorInNewTabOrWindow(target, ffi.id, _pi);
+ } else {
+ openMonitorInTheSameTab(target, ffi, _pi, updateCursorPos: false);
+ }
+ }
+}
+
+class _MainMonitorSwitchButton extends StatelessWidget {
+ final String id;
+ final FFI ffi;
+
+ const _MainMonitorSwitchButton({
+ Key? key,
+ required this.id,
+ required this.ffi,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ final cycle = _MonitorCycle(id, ffi);
+ return Obx(() {
+ if (cycle.total < 2) return const Offstage();
+ final label = cycle.label;
+
+ return _IconMenuButton(
+ tooltip: cycle.tooltip,
+ color: _ToolbarTheme.blueColor,
+ hoverColor: _ToolbarTheme.hoverBlueColor,
+ onPressed: cycle.next,
+ icon: SizedBox(
+ width: _ToolbarTheme.buttonSize,
+ height: _ToolbarTheme.buttonSize,
+ child: Stack(
+ alignment: const Alignment(0, -0.125),
+ children: [
+ SvgPicture.asset(
+ 'assets/display_switcher.svg',
+ colorFilter:
+ const ColorFilter.mode(Colors.white, BlendMode.srcIn),
+ width: _ToolbarTheme.buttonSize,
+ height: _ToolbarTheme.buttonSize,
+ ),
+ Text(
+ label,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ color: Colors.black,
+ fontSize: 11,
+ height: 1,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ });
+ }
+}
+
class _MonitorMenu extends StatelessWidget {
final String id;
final FFI ffi;
@@ -2971,6 +3065,7 @@ class RdoMenuButton extends StatelessWidget {
class _DraggableShowHide extends StatefulWidget {
final String id;
+ final FFI ffi;
final SessionID sessionId;
final RxDouble fraction;
final Rx<_ToolbarEdge> edge;
@@ -2994,6 +3089,7 @@ class _DraggableShowHide extends StatefulWidget {
const _DraggableShowHide({
Key? key,
required this.id,
+ required this.ffi,
required this.sessionId,
required this.fraction,
required this.edge,
@@ -3250,6 +3346,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> {
mainAxisSize: MainAxisSize.min,
children: [
_buildDraggable(context),
+ Obx(() => collapse.isTrue
+ ? _MinimizedMonitorSwitchButton(id: widget.id, ffi: widget.ffi)
+ : const Offstage()),
Obx(() => buttonWrapper(
() {
widget.setFullscreen(!isFullscreen.value);
@@ -3410,3 +3509,73 @@ class EdgeThicknessControl extends StatelessWidget {
return slider;
}
}
+
+class _MinimizedMonitorSwitchButton extends StatelessWidget {
+ final String id;
+ final FFI ffi;
+
+ const _MinimizedMonitorSwitchButton({
+ Key? key,
+ required this.id,
+ required this.ffi,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ const double iconSize = 20;
+ final cycle = _MonitorCycle(id, ffi);
+
+ return Obx(() {
+ final label = cycle.label;
+ if (!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar) ||
+ !mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMinToolbar)) {
+ return const Offstage();
+ }
+ if (cycle.total < 2) return const Offstage();
+ final privacyModeState = PrivacyModeState.find(id);
+ if (privacyModeState.isNotEmpty &&
+ !allowDisplaySwitchInPrivacyMode(
+ ffi.ffiModel.pi, privacyModeState.value)) {
+ return const Offstage();
+ }
+
+ return Tooltip(
+ message: cycle.tooltip,
+ child: TextButton(
+ onPressed: cycle.next,
+ style: ButtonStyle(
+ minimumSize: MaterialStateProperty.all(const Size(0, 0)),
+ padding: MaterialStateProperty.all(EdgeInsets.zero),
+ backgroundColor: MaterialStateProperty.resolveWith((states) {
+ if (states.contains(MaterialState.hovered)) {
+ return _ToolbarTheme.blueColor.withOpacity(0.15);
+ }
+ return null;
+ }),
+ ),
+ child: Stack(
+ alignment: const Alignment(0, -0.125),
+ children: [
+ SvgPicture.asset(
+ 'assets/display_switcher.svg',
+ colorFilter:
+ ColorFilter.mode(_ToolbarTheme.blueColor, BlendMode.srcIn),
+ width: iconSize,
+ height: iconSize,
+ ),
+ Text(
+ label,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 9,
+ height: 1,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ });
+ }
+}