import 'dart:convert'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/audio_input.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/input_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/plugin/widgets/desc_ui.dart'; import 'package:flutter_hbb/plugin/common.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:debounce_throttle/debounce_throttle.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:window_size/window_size.dart' as window_size; import '../../common.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../common/shared_state.dart'; import './popup_menu.dart'; import './kb_layout_type_chooser.dart'; class ToolbarState { late RxBool _pin; bool isShowInited = false; RxBool show = false.obs; ToolbarState() { _pin = RxBool(false); final s = bind.getLocalFlutterOption(k: kOptionRemoteMenubarState); if (s.isEmpty) { return; } try { final m = jsonDecode(s); if (m != null) { _pin = RxBool(m['pin'] ?? false); } } catch (e) { debugPrint('Failed to decode toolbar state ${e.toString()}'); } } bool get pin => _pin.value; switchShow(SessionID sessionId) async { bind.sessionToggleOption( sessionId: sessionId, value: kOptionCollapseToolbar); show.value = !show.value; } initShow(SessionID sessionId) async { if (!isShowInited) { show.value = !(await bind.sessionGetToggleOption( sessionId: sessionId, arg: kOptionCollapseToolbar) ?? false); isShowInited = true; } } switchPin() async { _pin.value = !_pin.value; // Save everytime changed, as this func will not be called frequently await _savePin(); } setPin(bool v) async { if (_pin.value != v) { _pin.value = v; // Save everytime changed, as this func will not be called frequently await _savePin(); } } _savePin() async { bind.setLocalFlutterOption( k: kOptionRemoteMenubarState, v: jsonEncode({'pin': _pin.value})); } } class _ToolbarTheme { static const Color blueColor = MyTheme.button; static const Color hoverBlueColor = MyTheme.accent; static Color inactiveColor = Colors.grey[800]!; static Color hoverInactiveColor = Colors.grey[850]!; static const Color redColor = Colors.redAccent; static const Color hoverRedColor = Colors.red; // kMinInteractiveDimension static const double height = 20.0; static const double dividerHeight = 12.0; static const double buttonSize = 32; static const double buttonHMargin = 2; static const double buttonVMargin = 6; static const double iconRadius = 8; static const double elevation = 3; static double dividerSpaceToAction = isWindows ? 8 : 14; static double menuBorderRadius = isWindows ? 5.0 : 7.0; static EdgeInsets menuPadding = isWindows ? EdgeInsets.fromLTRB(4, 12, 4, 12) : EdgeInsets.fromLTRB(6, 14, 6, 14); static const double menuButtonBorderRadius = 3.0; static Color borderColor(BuildContext context) => MyTheme.color(context).border3 ?? MyTheme.border; static Color? dividerColor(BuildContext context) => MyTheme.color(context).divider; static MenuStyle defaultMenuStyle(BuildContext context) => MenuStyle( side: MaterialStateProperty.all(BorderSide( width: 1, color: borderColor(context), )), shape: MaterialStatePropertyAll(RoundedRectangleBorder( borderRadius: BorderRadius.circular(_ToolbarTheme.menuBorderRadius))), padding: MaterialStateProperty.all(_ToolbarTheme.menuPadding), ); static final defaultMenuButtonStyle = ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.transparent), padding: MaterialStatePropertyAll(EdgeInsets.zero), overlayColor: MaterialStatePropertyAll(Colors.transparent), ); static Widget borderWrapper( BuildContext context, Widget child, BorderRadius borderRadius) { return Container( decoration: BoxDecoration( border: Border.all( color: borderColor(context), width: 1, ), borderRadius: borderRadius, ), child: child, ); } } typedef DismissFunc = void Function(); class RemoteMenuEntry { static MenuEntryRadios viewStyle( String remoteId, FFI ffi, EdgeInsets padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, RxString? rxViewStyle, }) { return MenuEntryRadios( text: translate('Ratio'), optionsGetter: () => [ MenuEntryRadioOption( text: translate('Scale original'), value: kRemoteViewStyleOriginal, dismissOnClicked: true, dismissCallback: dismissCallback, ), MenuEntryRadioOption( text: translate('Scale adaptive'), value: kRemoteViewStyleAdaptive, dismissOnClicked: true, dismissCallback: dismissCallback, ), ], curOptionGetter: () async { // null means peer id is not found, which there's no need to care about final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (rxViewStyle != null) { rxViewStyle.value = viewStyle; } return viewStyle; }, optionSetter: (String oldValue, String newValue) async { await bind.sessionSetViewStyle( sessionId: ffi.sessionId, value: newValue); if (rxViewStyle != null) { rxViewStyle.value = newValue; } ffi.canvasModel.updateViewStyle(); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } static MenuEntrySwitch2 showRemoteCursor( String remoteId, SessionID sessionId, EdgeInsets padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { final state = ShowRemoteCursorState.find(remoteId); final optKey = 'show-remote-cursor'; return MenuEntrySwitch2( switchType: SwitchType.scheckbox, text: translate('Show remote cursor'), getter: () { return state; }, setter: (bool v) async { await bind.sessionToggleOption(sessionId: sessionId, value: optKey); state.value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: optKey); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } static MenuEntrySwitch disableClipboard( SessionID sessionId, EdgeInsets? padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return createSwitchMenuEntry( sessionId, 'Disable clipboard', 'disable-clipboard', padding, true, dismissCallback: dismissCallback, ); } static MenuEntrySwitch createSwitchMenuEntry( SessionID sessionId, String text, String option, EdgeInsets? padding, bool dismissOnClicked, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return MenuEntrySwitch( switchType: SwitchType.scheckbox, text: translate(text), getter: () async { return bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: option); }, setter: (bool v) async { await bind.sessionToggleOption(sessionId: sessionId, value: option); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: dismissOnClicked, dismissCallback: dismissCallback, ); } static MenuEntryButton insertLock( SessionID sessionId, EdgeInsets? padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Insert Lock'), style: style, ), proc: () { bind.sessionLockScreen(sessionId: sessionId); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } static insertCtrlAltDel( SessionID sessionId, EdgeInsets? padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( '${translate("Insert")} Ctrl + Alt + Del', style: style, ), proc: () { bind.sessionCtrlAltDel(sessionId: sessionId); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } } class RemoteToolbar extends StatefulWidget { final String id; final FFI ffi; final ToolbarState state; final Function(int, Function(bool)) onEnterOrLeaveImageSetter; final Function(int) onEnterOrLeaveImageCleaner; final Function(VoidCallback) setRemoteState; RemoteToolbar({ Key? key, required this.id, required this.ffi, required this.state, required this.onEnterOrLeaveImageSetter, required this.onEnterOrLeaveImageCleaner, required this.setRemoteState, }) : super(key: key); @override State createState() => _RemoteToolbarState(); } class _RemoteToolbarState extends State { late Debouncer _debouncerHide; bool _isCursorOverImage = false; final _fractionX = 0.5.obs; final _dragging = false.obs; int get windowId => stateGlobal.windowId; void _setFullscreen(bool v) { stateGlobal.setFullscreen(v); // stateGlobal.fullscreen is RxBool now, no need to call setState. // setState(() {}); } RxBool get show => widget.state.show; bool get pin => widget.state.pin; PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; triggerAutoHide() => _debouncerHide.value = _debouncerHide.value + 1; void _minimize() async => await WindowController.fromWindowId(windowId).minimize(); @override initState() { super.initState(); Future.delayed(Duration.zero, () async { _fractionX.value = double.tryParse(await bind.sessionGetOption( sessionId: widget.ffi.sessionId, arg: 'remote-menubar-drag-x') ?? '0.5') ?? 0.5; }); _debouncerHide = Debouncer( Duration(milliseconds: 5000), onChanged: _debouncerHideProc, initialValue: 0, ); widget.onEnterOrLeaveImageSetter(identityHashCode(this), (enter) { if (enter) { triggerAutoHide(); _isCursorOverImage = true; } else { _isCursorOverImage = false; } }); } _debouncerHideProc(int v) { if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) { show.value = false; } } @override dispose() { super.dispose(); widget.onEnterOrLeaveImageCleaner(identityHashCode(this)); } @override Widget build(BuildContext context) { return Align( alignment: Alignment.topCenter, child: Obx(() => show.value ? _buildToolbar(context) : _buildDraggableShowHide(context)), ); } Widget _buildDraggableShowHide(BuildContext context) { return Obx(() { if (show.isTrue && _dragging.isFalse) { triggerAutoHide(); } final borderRadius = BorderRadius.vertical( bottom: Radius.circular(5), ); return Align( alignment: FractionalOffset(_fractionX.value, 0), child: Offstage( offstage: _dragging.isTrue, child: Material( elevation: _ToolbarTheme.elevation, shadowColor: MyTheme.color(context).shadow, borderRadius: borderRadius, child: _DraggableShowHide( sessionId: widget.ffi.sessionId, dragging: _dragging, fractionX: _fractionX, toolbarState: widget.state, setFullscreen: _setFullscreen, setMinimize: _minimize, borderRadius: borderRadius, ), ), ), ); }); } Widget _buildToolbar(BuildContext context) { final List toolbarItems = []; if (!isWebDesktop) { toolbarItems.add(_PinMenu(state: widget.state)); toolbarItems.add(_MobileActionMenu(ffi: widget.ffi)); } toolbarItems.add(Obx(() { if (PrivacyModeState.find(widget.id).isEmpty && pi.displaysCount.value > 1) { return _MonitorMenu( id: widget.id, ffi: widget.ffi, setRemoteState: widget.setRemoteState); } else { return Offstage(); } })); toolbarItems .add(_ControlMenu(id: widget.id, ffi: widget.ffi, state: widget.state)); toolbarItems.add(_DisplayMenu( id: widget.id, ffi: widget.ffi, state: widget.state, setFullscreen: _setFullscreen, )); toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi)); if (!isWeb) { toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi)); toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi)); } if (!isWeb) toolbarItems.add(_RecordMenu()); toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi)); final toolbarBorderRadius = BorderRadius.all(Radius.circular(4.0)); return Column( mainAxisSize: MainAxisSize.min, children: [ Material( elevation: _ToolbarTheme.elevation, shadowColor: MyTheme.color(context).shadow, borderRadius: toolbarBorderRadius, color: Theme.of(context) .menuBarTheme .style ?.backgroundColor ?.resolve(MaterialState.values.toSet()), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Theme( data: themeData(), child: _ToolbarTheme.borderWrapper( context, Row( children: [ SizedBox(width: _ToolbarTheme.buttonHMargin * 2), ...toolbarItems, SizedBox(width: _ToolbarTheme.buttonHMargin * 2) ], ), toolbarBorderRadius), ), ), ), _buildDraggableShowHide(context), ], ); } ThemeData themeData() { return Theme.of(context).copyWith( menuButtonTheme: MenuButtonThemeData( style: ButtonStyle( minimumSize: MaterialStatePropertyAll(Size(64, 32)), textStyle: MaterialStatePropertyAll( TextStyle(fontWeight: FontWeight.normal), ), shape: MaterialStatePropertyAll(RoundedRectangleBorder( borderRadius: BorderRadius.circular(_ToolbarTheme.menuButtonBorderRadius))), ), ), dividerTheme: DividerThemeData( space: _ToolbarTheme.dividerSpaceToAction, color: _ToolbarTheme.dividerColor(context), ), menuBarTheme: MenuBarThemeData( style: MenuStyle( padding: MaterialStatePropertyAll(EdgeInsets.zero), elevation: MaterialStatePropertyAll(0), shape: MaterialStatePropertyAll(BeveledRectangleBorder()), ).copyWith( backgroundColor: Theme.of(context).menuBarTheme.style?.backgroundColor)), ); } } class _PinMenu extends StatelessWidget { final ToolbarState state; const _PinMenu({Key? key, required this.state}) : super(key: key); @override Widget build(BuildContext context) { return Obx( () => _IconMenuButton( assetName: state.pin ? "assets/pinned.svg" : "assets/unpinned.svg", tooltip: state.pin ? 'Unpin Toolbar' : 'Pin Toolbar', onPressed: state.switchPin, color: state.pin ? _ToolbarTheme.blueColor : _ToolbarTheme.inactiveColor, hoverColor: state.pin ? _ToolbarTheme.hoverBlueColor : _ToolbarTheme.hoverInactiveColor, ), ); } } class _MobileActionMenu extends StatelessWidget { final FFI ffi; const _MobileActionMenu({Key? key, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { if (!ffi.ffiModel.isPeerAndroid) return Offstage(); return Obx(() => _IconMenuButton( assetName: 'assets/actions_mobile.svg', tooltip: 'Mobile Actions', onPressed: () => ffi.dialogManager.setMobileActionsOverlayVisible( !ffi.dialogManager.mobileActionsOverlayVisible.value), color: ffi.dialogManager.mobileActionsOverlayVisible.isTrue ? _ToolbarTheme.blueColor : _ToolbarTheme.inactiveColor, hoverColor: ffi.dialogManager.mobileActionsOverlayVisible.isTrue ? _ToolbarTheme.hoverBlueColor : _ToolbarTheme.hoverInactiveColor, )); } } class _MonitorMenu extends StatelessWidget { final String id; final FFI ffi; final Function(VoidCallback) setRemoteState; const _MonitorMenu({ Key? key, required this.id, required this.ffi, required this.setRemoteState, }) : super(key: key); bool get showMonitorsToolbar => bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y'; bool get supportIndividualWindows => !isWeb && ffi.ffiModel.pi.isSupportMultiDisplay; @override Widget build(BuildContext context) => showMonitorsToolbar ? buildMultiMonitorMenu(context) : Obx(() => buildMonitorMenu(context)); Widget buildMonitorMenu(BuildContext context) { final width = SimpleWrapper(0); final monitorsIcon = globalMonitorsWidget(width, Colors.white, Colors.black38); return _IconSubmenuButton( tooltip: 'Select Monitor', icon: monitorsIcon, ffi: ffi, width: width.value, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuStyle: MenuStyle( padding: MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))), menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]); } Widget buildMultiMonitorMenu(BuildContext context) { return Row(children: buildMonitorList(context, true)); } Widget buildMonitorSubmenuWidget(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Row(children: buildMonitorList(context, false)), supportIndividualWindows ? Divider() : Offstage(), supportIndividualWindows ? chooseDisplayBehavior() : Offstage(), ], ); } Widget chooseDisplayBehavior() { final value = bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) == 'Y'; return CkbMenuButton( value: value, onChanged: (value) async { if (value == null) return; await bind.sessionSetDisplaysAsIndividualWindows( sessionId: ffi.sessionId, value: value ? 'Y' : 'N'); }, ffi: ffi, child: Text(translate('Show displays as individual windows'))); } buildOneMonitorButton(i, curDisplay) => Text( '${i + 1}', style: TextStyle( color: i == curDisplay ? _ToolbarTheme.blueColor : _ToolbarTheme.inactiveColor, fontSize: 12, fontWeight: FontWeight.bold, ), ); List buildMonitorList(BuildContext context, bool isMulti) { final List monitorList = []; final pi = ffi.ffiModel.pi; buildMonitorButton(int i) => Obx(() { RxInt display = CurrentDisplayState.find(id); final isAllMonitors = i == kAllDisplayValue; final width = SimpleWrapper(0); Widget? monitorsIcon; if (isAllMonitors) { monitorsIcon = globalMonitorsWidget( width, Colors.white, _ToolbarTheme.blueColor); } return _IconMenuButton( tooltip: isMulti ? '' : isAllMonitors ? 'all monitors' : '#${i + 1} monitor', hMargin: isMulti ? null : 6, vMargin: isMulti ? null : 12, topLevel: false, color: i == display.value ? _ToolbarTheme.blueColor : _ToolbarTheme.inactiveColor, hoverColor: i == display.value ? _ToolbarTheme.hoverBlueColor : _ToolbarTheme.hoverInactiveColor, width: isAllMonitors ? width.value : null, icon: isAllMonitors ? monitorsIcon : Container( alignment: AlignmentDirectional.center, constraints: const BoxConstraints(minHeight: _ToolbarTheme.height), child: Stack( alignment: Alignment.center, children: [ SvgPicture.asset( "assets/screen.svg", colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Obx(() => buildOneMonitorButton(i, display.value)), ], ), ), onPressed: () => onPressed(i, pi, isMulti), ); }); for (int i = 0; i < pi.displays.length; i++) { monitorList.add(buildMonitorButton(i)); } if (supportIndividualWindows && pi.displays.length > 1) { monitorList.add(buildMonitorButton(kAllDisplayValue)); } return monitorList; } globalMonitorsWidget( SimpleWrapper width, Color activeTextColor, Color activeBgColor) { getMonitors() { final pi = ffi.ffiModel.pi; RxInt display = CurrentDisplayState.find(id); final rect = ffi.ffiModel.globalDisplaysRect(); if (rect == null) { return Offstage(); } final scale = _ToolbarTheme.buttonSize / rect.height * 0.75; final startY = (_ToolbarTheme.buttonSize - rect.height * scale) * 0.5; final startX = startY; final children = []; for (var i = 0; i < pi.displays.length; i++) { final d = pi.displays[i]; double s = d.scale; int dWidth = d.width.toDouble() ~/ s; int dHeight = d.height.toDouble() ~/ s; final fontSize = (dWidth * scale < dHeight * scale ? dWidth * scale : dHeight * scale) * 0.65; children.add(Positioned( left: (d.x - rect.left) * scale + startX, top: (d.y - rect.top) * scale + startY, width: dWidth * scale, height: dHeight * scale, child: Container( decoration: BoxDecoration( border: Border.all( color: Colors.grey, width: 1.0, ), color: display.value == i ? activeBgColor : Colors.white, ), child: Center( child: Text( '${i + 1}', style: TextStyle( color: display.value == i ? activeTextColor : _ToolbarTheme.inactiveColor, fontSize: fontSize, fontWeight: FontWeight.bold, ), )), ), )); } width.value = rect.width * scale + startX * 2; return SizedBox( width: width.value, height: rect.height * scale + startY * 2, child: Stack( children: children, ), ); } return Stack( alignment: Alignment.center, children: [ SizedBox(height: _ToolbarTheme.buttonSize), getMonitors(), ], ); } onPressed(int i, PeerInfo pi, bool isMulti) { if (!isMulti) { // If show monitors in toolbar(`buildMultiMonitorMenu()`), then the menu will dismiss automatically. _menuDismissCallback(ffi); } RxInt display = CurrentDisplayState.find(id); if (display.value != i) { final isChooseDisplayToOpenInNewWindow = pi.isSupportMultiDisplay && bind.sessionGetDisplaysAsIndividualWindows( sessionId: ffi.sessionId) == 'Y'; if (isChooseDisplayToOpenInNewWindow) { openMonitorInNewTabOrWindow(i, ffi.id, pi); } else { openMonitorInTheSameTab(i, ffi, pi, updateCursorPos: !isMulti); } } } } class _ControlMenu extends StatelessWidget { final String id; final FFI ffi; final ToolbarState state; _ControlMenu( {Key? key, required this.id, required this.ffi, required this.state}) : super(key: key); @override Widget build(BuildContext context) { return _IconSubmenuButton( tooltip: 'Control Actions', svg: "assets/actions.svg", color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, ffi: ffi, menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) { if (e.divider) { return Divider(); } else { return MenuButton( child: e.child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); } }).toList()); } } class ScreenAdjustor { final String id; final FFI ffi; final VoidCallback cbExitFullscreen; window_size.Screen? _screen; ScreenAdjustor({ required this.id, required this.ffi, required this.cbExitFullscreen, }); bool get isFullscreen => stateGlobal.fullscreen.isTrue; int get windowId => stateGlobal.windowId; adjustWindow(BuildContext context) { return futureBuilder( future: isWindowCanBeAdjusted(), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); return Column( children: [ MenuButton( child: Text(translate('Adjust Window')), onPressed: () => doAdjustWindow(context), ffi: ffi), Divider(), ], ); }); } doAdjustWindow(BuildContext context) async { await updateScreen(); if (_screen != null) { cbExitFullscreen(); double scale = _screen!.scaleFactor; final wndRect = await WindowController.fromWindowId(windowId).getFrame(); final mediaSize = MediaQueryData.fromView(View.of(context)).size; // On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. // https://stackoverflow.com/a/7561083 double magicWidth = wndRect.right - wndRect.left - mediaSize.width * scale; double magicHeight = wndRect.bottom - wndRect.top - mediaSize.height * scale; final canvasModel = ffi.canvasModel; final width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + CanvasModel.rightToEdge) * scale + magicWidth; final height = (canvasModel.getDisplayHeight() * canvasModel.scale + CanvasModel.topToEdge + CanvasModel.bottomToEdge) * scale + magicHeight; double left = wndRect.left + (wndRect.width - width) / 2; double top = wndRect.top + (wndRect.height - height) / 2; Rect frameRect = _screen!.frame; if (!isFullscreen) { frameRect = _screen!.visibleFrame; } if (left < frameRect.left) { left = frameRect.left; } if (top < frameRect.top) { top = frameRect.top; } if ((left + width) > frameRect.right) { left = frameRect.right - width; } if ((top + height) > frameRect.bottom) { top = frameRect.bottom - height; } await WindowController.fromWindowId(windowId) .setFrame(Rect.fromLTWH(left, top, width, height)); stateGlobal.setMaximized(false); } } updateScreen() async { final String info = isWeb ? screenInfo : await _getScreenInfoDesktop() ?? ''; if (info.isEmpty) { _screen = null; } else { final screenMap = jsonDecode(info); _screen = window_size.Screen( Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'], screenMap['frame']['r'], screenMap['frame']['b']), Rect.fromLTRB( screenMap['visibleFrame']['l'], screenMap['visibleFrame']['t'], screenMap['visibleFrame']['r'], screenMap['visibleFrame']['b']), screenMap['scaleFactor']); } } _getScreenInfoDesktop() async { final v = await rustDeskWinManager.call( WindowType.Main, kWindowGetWindowInfo, ''); return v.result; } Future isWindowCanBeAdjusted() async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { return false; } if (!isWeb) { final remoteCount = RemoteCountState.find().value; if (remoteCount != 1) { return false; } } if (_screen == null) { return false; } final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor; double selfWidth = _screen!.visibleFrame.width; double selfHeight = _screen!.visibleFrame.height; if (isFullscreen) { selfWidth = _screen!.frame.width; selfHeight = _screen!.frame.height; } final canvasModel = ffi.canvasModel; final displayWidth = canvasModel.getDisplayWidth(); final displayHeight = canvasModel.getDisplayHeight(); final requiredWidth = CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge; final requiredHeight = CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge; return selfWidth > (requiredWidth * scale) && selfHeight > (requiredHeight * scale); } } class _DisplayMenu extends StatefulWidget { final String id; final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; final Widget pluginItem; _DisplayMenu( {Key? key, required this.id, required this.ffi, required this.state, required this.setFullscreen}) : pluginItem = LocationItem.createLocationItem( id, ffi, kLocationClientRemoteToolbarDisplay, true, ), super(key: key); @override State<_DisplayMenu> createState() => _DisplayMenuState(); } class _DisplayMenuState extends State<_DisplayMenu> { late final ScreenAdjustor _screenAdjustor = ScreenAdjustor( id: widget.id, ffi: widget.ffi, cbExitFullscreen: () => widget.setFullscreen(false), ); int get windowId => stateGlobal.windowId; Map get perms => widget.ffi.ffiModel.permissions; PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; FFI get ffi => widget.ffi; String get id => widget.id; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { _screenAdjustor.updateScreen(); menuChildrenGetter() { final menuChildren = [ _screenAdjustor.adjustWindow(context), viewStyle(), scrollStyle(), imageQuality(), codec(), _ResolutionsMenu( id: widget.id, ffi: widget.ffi, screenAdjustor: _screenAdjustor, ), if (pi.isRustDeskIdd) _RustDeskVirtualDisplayMenu( id: widget.id, ffi: widget.ffi, ), if (pi.isAmyuniIdd) _AmyuniVirtualDisplayMenu( id: widget.id, ffi: widget.ffi, ), cursorToggles(), Divider(), toggles(), ]; // privacy mode if (ffiModel.keyboard && pi.features.privacyMode) { final privacyModeState = PrivacyModeState.find(id); final privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, ffi); if (privacyModeList.length == 1) { menuChildren.add(CkbMenuButton( value: privacyModeList[0].value, onChanged: privacyModeList[0].onChanged, child: privacyModeList[0].child, ffi: ffi)); } else if (privacyModeList.length > 1) { menuChildren.addAll([ Divider(), _SubmenuButton( ffi: widget.ffi, child: Text(translate('Privacy mode')), menuChildren: privacyModeList .map((e) => CkbMenuButton( value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList()), ]); } } menuChildren.add(widget.pluginItem); return menuChildren; } return _IconSubmenuButton( tooltip: 'Display Settings', svg: "assets/display.svg", ffi: widget.ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildrenGetter: menuChildrenGetter, ); } viewStyle() { return futureBuilder( future: toolbarViewStyle(context, widget.id, widget.ffi), hasData: (data) { final v = data as List>; return Column(children: [ ...v .map((e) => RdoMenuButton( value: e.value, groupValue: e.groupValue, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList(), Divider(), ]); }); } scrollStyle() { return futureBuilder(future: () async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; final visible = viewStyle == kRemoteViewStyleOriginal; final scrollStyle = await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? ''; return {'visible': visible, 'scrollStyle': scrollStyle}; }(), hasData: (data) { final visible = data['visible'] as bool; if (!visible) return Offstage(); final groupValue = data['scrollStyle'] as String; onChange(String? value) async { if (value == null) return; await bind.sessionSetScrollStyle( sessionId: ffi.sessionId, value: value); widget.ffi.canvasModel.updateScrollStyle(); } final enabled = widget.ffi.canvasModel.imageOverflow.value; return Column(children: [ RdoMenuButton( child: Text(translate('ScrollAuto')), value: kRemoteScrollStyleAuto, groupValue: groupValue, onChanged: enabled ? (value) => onChange(value) : null, ffi: widget.ffi, ), RdoMenuButton( child: Text(translate('Scrollbar')), value: kRemoteScrollStyleBar, groupValue: groupValue, onChanged: enabled ? (value) => onChange(value) : null, ffi: widget.ffi, ), Divider(), ]); }); } imageQuality() { return futureBuilder( future: toolbarImageQuality(context, widget.id, widget.ffi), hasData: (data) { final v = data as List>; return _SubmenuButton( ffi: widget.ffi, child: Text(translate('Image Quality')), menuChildren: v .map((e) => RdoMenuButton( value: e.value, groupValue: e.groupValue, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList(), ); }); } codec() { return futureBuilder( future: toolbarCodec(context, id, ffi), hasData: (data) { final v = data as List>; if (v.isEmpty) return Offstage(); return _SubmenuButton( ffi: widget.ffi, child: Text(translate('Codec')), menuChildren: v .map((e) => RdoMenuButton( value: e.value, groupValue: e.groupValue, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList()); }); } cursorToggles() { return futureBuilder( future: toolbarCursor(context, id, ffi), hasData: (data) { final v = data as List; if (v.isEmpty) return Offstage(); return Column(children: [ Divider(), ...v .map((e) => CkbMenuButton( value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList(), ]); }); } toggles() { return futureBuilder( future: toolbarDisplayToggle(context, id, ffi), hasData: (data) { final v = data as List; if (v.isEmpty) return Offstage(); return Column( children: v .map((e) => CkbMenuButton( value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList()); }); } } class _ResolutionsMenu extends StatefulWidget { final String id; final FFI ffi; final ScreenAdjustor screenAdjustor; _ResolutionsMenu({ Key? key, required this.id, required this.ffi, required this.screenAdjustor, }) : super(key: key); @override State<_ResolutionsMenu> createState() => _ResolutionsMenuState(); } const double _kCustomResolutionEditingWidth = 42; const _kCustomResolutionValue = 'custom'; class _ResolutionsMenuState extends State<_ResolutionsMenu> { String _groupValue = ''; Resolution? _localResolution; late final TextEditingController _customWidth = TextEditingController(text: rect?.width.toInt().toString() ?? ''); late final TextEditingController _customHeight = TextEditingController(text: rect?.height.toInt().toString() ?? ''); FFI get ffi => widget.ffi; PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; Rect? get rect => scaledRect(); List get resolutions => pi.resolutions; bool get isWayland => bind.mainCurrentIsWayland(); @override void initState() { super.initState(); _getLocalResolutionWayland(); } Rect? scaledRect() { final scale = pi.scaleOfDisplay(pi.currentDisplay); final rect = ffiModel.rect; if (rect == null) { return null; } return Rect.fromLTWH( rect.left, rect.top, rect.width / scale, rect.height / scale, ); } @override Widget build(BuildContext context) { final isVirtualDisplay = ffiModel.isVirtualDisplayResolution; final visible = ffiModel.keyboard && (isVirtualDisplay || resolutions.length > 1) && pi.currentDisplay != kAllDisplayValue; if (!visible) return Offstage(); final showOriginalBtn = ffiModel.isOriginalResolutionSet && !ffiModel.isOriginalResolution; final showFitLocalBtn = !_isRemoteResolutionFitLocal(); _setGroupValue(); return _SubmenuButton( ffi: widget.ffi, menuChildren: [ _OriginalResolutionMenuButton(context, showOriginalBtn), _FitLocalResolutionMenuButton(context, showFitLocalBtn), _customResolutionMenuButton(context, isVirtualDisplay), _menuDivider(showOriginalBtn, showFitLocalBtn, isVirtualDisplay), ] + _supportedResolutionMenuButtons(), child: Text(translate("Resolution")), ); } _setGroupValue() { if (pi.currentDisplay == kAllDisplayValue) { return; } final lastGroupValue = stateGlobal.getLastResolutionGroupValue(widget.id, pi.currentDisplay); if (lastGroupValue == _kCustomResolutionValue) { _groupValue = _kCustomResolutionValue; } else { _groupValue = '${(rect?.width ?? 0).toInt()}x${(rect?.height ?? 0).toInt()}'; } } _menuDivider( bool showOriginalBtn, bool showFitLocalBtn, bool isVirtualDisplay) { return Offstage( offstage: !(showOriginalBtn || showFitLocalBtn || isVirtualDisplay), child: Divider(), ); } Future _getLocalResolutionWayland() async { if (!isWayland) return _getLocalResolution(); final window = await window_size.getWindowInfo(); final screen = window.screen; if (screen != null) { setState(() { _localResolution = Resolution( screen.frame.width.toInt(), screen.frame.height.toInt(), ); }); } } _getLocalResolution() { _localResolution = null; final String mainDisplay = bind.mainGetMainDisplay(); if (mainDisplay.isNotEmpty) { try { final display = json.decode(mainDisplay); if (display['w'] != null && display['h'] != null) { _localResolution = Resolution(display['w'], display['h']); if (isWeb) { if (display['scaleFactor'] != null) { _localResolution = Resolution( (display['w'] / display['scaleFactor']).toInt(), (display['h'] / display['scaleFactor']).toInt(), ); } } } } catch (e) { debugPrint('Failed to decode $mainDisplay, $e'); } } } // This widget has been unmounted, so the State no longer has a context _onChanged(String? value) async { if (pi.currentDisplay == kAllDisplayValue) { return; } stateGlobal.setLastResolutionGroupValue( widget.id, pi.currentDisplay, value); if (value == null) return; int? w; int? h; if (value == _kCustomResolutionValue) { w = int.tryParse(_customWidth.text); h = int.tryParse(_customHeight.text); } else { final list = value.split('x'); if (list.length == 2) { w = int.tryParse(list[0]); h = int.tryParse(list[1]); } } if (w != null && h != null) { if (w != rect?.width.toInt() || h != rect?.height.toInt()) { await _changeResolution(w, h); } } } _changeResolution(int w, int h) async { if (pi.currentDisplay == kAllDisplayValue) { return; } await bind.sessionChangeResolution( sessionId: ffi.sessionId, display: pi.currentDisplay, width: w, height: h, ); Future.delayed(Duration(seconds: 3), () async { final rect = ffiModel.rect; if (rect == null) { return; } if (w == rect.width.toInt() && h == rect.height.toInt()) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { widget.screenAdjustor.doAdjustWindow(context); } } }); } Widget _OriginalResolutionMenuButton( BuildContext context, bool showOriginalBtn) { final display = pi.tryGetDisplayIfNotAllDisplay(); if (display == null) { return Offstage(); } if (!resolutions.any((e) => e.width == display.originalWidth && e.height == display.originalHeight)) { return Offstage(); } return Offstage( offstage: !showOriginalBtn, child: MenuButton( onPressed: () => _changeResolution(display.originalWidth, display.originalHeight), ffi: widget.ffi, child: Text( '${translate('resolution_original_tip')} ${display.originalWidth}x${display.originalHeight}'), ), ); } Widget _FitLocalResolutionMenuButton( BuildContext context, bool showFitLocalBtn) { return Offstage( offstage: !showFitLocalBtn, child: MenuButton( onPressed: () { final resolution = _getBestFitResolution(); if (resolution != null) { _changeResolution(resolution.width, resolution.height); } }, ffi: widget.ffi, child: Text( '${translate('resolution_fit_local_tip')} ${_localResolution?.width ?? 0}x${_localResolution?.height ?? 0}'), ), ); } Widget _customResolutionMenuButton(BuildContext context, isVirtualDisplay) { return Offstage( offstage: !isVirtualDisplay, child: RdoMenuButton( value: _kCustomResolutionValue, groupValue: _groupValue, onChanged: (String? value) => _onChanged(value), ffi: widget.ffi, child: Row( children: [ Text('${translate('resolution_custom_tip')} '), SizedBox( width: _kCustomResolutionEditingWidth, child: _resolutionInput(_customWidth), ), Text(' x '), SizedBox( width: _kCustomResolutionEditingWidth, child: _resolutionInput(_customHeight), ), ], ), ), ); } TextField _resolutionInput(TextEditingController controller) { return TextField( decoration: InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.fromLTRB(3, 3, 3, 3), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4), FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), ], controller: controller, ); } List _supportedResolutionMenuButtons() => resolutions .map((e) => RdoMenuButton( value: '${e.width}x${e.height}', groupValue: _groupValue, onChanged: (String? value) => _onChanged(value), ffi: widget.ffi, child: Text('${e.width}x${e.height}'))) .toList(); Resolution? _getBestFitResolution() { if (_localResolution == null) { return null; } if (ffiModel.isVirtualDisplayResolution) { return _localResolution!; } for (final r in resolutions) { if (r.width == _localResolution!.width && r.height == _localResolution!.height) { return r; } } return null; } bool _isRemoteResolutionFitLocal() { if (_localResolution == null) { return true; } final bestFitResolution = _getBestFitResolution(); if (bestFitResolution == null) { return true; } return bestFitResolution.width == rect?.width.toInt() && bestFitResolution.height == rect?.height.toInt(); } } class _RustDeskVirtualDisplayMenu extends StatefulWidget { final String id; final FFI ffi; _RustDeskVirtualDisplayMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override State<_RustDeskVirtualDisplayMenu> createState() => _RustDeskVirtualDisplayMenuState(); } class _RustDeskVirtualDisplayMenuState extends State<_RustDeskVirtualDisplayMenu> { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { if (widget.ffi.ffiModel.pi.platform != kPeerPlatformWindows) { return Offstage(); } if (!widget.ffi.ffiModel.pi.isInstalled) { return Offstage(); } final virtualDisplays = widget.ffi.ffiModel.pi.RustDeskVirtualDisplays; final privacyModeState = PrivacyModeState.find(widget.id); final children = []; for (var i = 0; i < kMaxVirtualDisplayCount; i++) { children.add(Obx(() => CkbMenuButton( value: virtualDisplays.contains(i + 1), onChanged: privacyModeState.isNotEmpty ? null : (bool? value) async { if (value != null) { bind.sessionToggleVirtualDisplay( sessionId: widget.ffi.sessionId, index: i + 1, on: value); } }, child: Text('${translate('Virtual display')} ${i + 1}'), ffi: widget.ffi, ))); } children.add(Divider()); children.add(Obx(() => MenuButton( onPressed: privacyModeState.isNotEmpty ? null : () { bind.sessionToggleVirtualDisplay( sessionId: widget.ffi.sessionId, index: kAllVirtualDisplay, on: false); }, ffi: widget.ffi, child: Text(translate('Plug out all')), ))); return _SubmenuButton( ffi: widget.ffi, menuChildren: children, child: Text(translate("Virtual display")), ); } } class _AmyuniVirtualDisplayMenu extends StatefulWidget { final String id; final FFI ffi; _AmyuniVirtualDisplayMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override State<_AmyuniVirtualDisplayMenu> createState() => _AmiyuniVirtualDisplayMenuState(); } class _AmiyuniVirtualDisplayMenuState extends State<_AmyuniVirtualDisplayMenu> { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { if (widget.ffi.ffiModel.pi.platform != kPeerPlatformWindows) { return Offstage(); } if (!widget.ffi.ffiModel.pi.isInstalled) { return Offstage(); } final count = widget.ffi.ffiModel.pi.amyuniVirtualDisplayCount; final privacyModeState = PrivacyModeState.find(widget.id); final children = [ Obx(() => Row( children: [ TextButton( onPressed: privacyModeState.isNotEmpty || count == 0 ? null : () => bind.sessionToggleVirtualDisplay( sessionId: widget.ffi.sessionId, index: 0, on: false), child: Icon(Icons.remove), ), Text(count.toString()), TextButton( onPressed: privacyModeState.isNotEmpty || count == 4 ? null : () => bind.sessionToggleVirtualDisplay( sessionId: widget.ffi.sessionId, index: 0, on: true), child: Icon(Icons.add), ), ], )), Divider(), Obx(() => MenuButton( onPressed: privacyModeState.isNotEmpty || count == 0 ? null : () { bind.sessionToggleVirtualDisplay( sessionId: widget.ffi.sessionId, index: kAllVirtualDisplay, on: false); }, ffi: widget.ffi, child: Text(translate('Plug out all')), )), ]; return _SubmenuButton( ffi: widget.ffi, menuChildren: children, child: Text(translate("Virtual display")), ); } } class _KeyboardMenu extends StatelessWidget { final String id; final FFI ffi; _KeyboardMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); PeerInfo get pi => ffi.ffiModel.pi; @override Widget build(BuildContext context) { var ffiModel = Provider.of(context); if (!ffiModel.keyboard) return Offstage(); toolbarToggles() => toolbarKeyboardToggles(ffi) .map((e) => CkbMenuButton( value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList(); return _IconSubmenuButton( tooltip: 'Keyboard Settings', svg: "assets/keyboard.svg", ffi: ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildrenGetter: () => [ keyboardMode(), localKeyboardType(), inputSource(), Divider(), viewMode(), Divider(), ...toolbarToggles(), ...mobileActions(), ]); } keyboardMode() { return futureBuilder(future: () async { return await bind.sessionGetKeyboardMode(sessionId: ffi.sessionId) ?? kKeyLegacyMode; }(), hasData: (data) { final groupValue = data as String; List modes = [ InputModeMenu(key: kKeyLegacyMode, menu: 'Legacy mode'), InputModeMenu(key: kKeyMapMode, menu: 'Map mode'), InputModeMenu(key: kKeyTranslateMode, menu: 'Translate mode'), ]; List list = []; final enabled = !ffi.ffiModel.viewOnly; onChanged(String? value) async { if (value == null) return; await bind.sessionSetKeyboardMode( sessionId: ffi.sessionId, value: value); await ffi.inputModel.updateKeyboardMode(); } // If use flutter to grab keys, we can only use one mode. // Map mode and Legacy mode, at least one of them is supported. String? modeOnly; if (isInputSourceFlutter) { if (bind.sessionIsKeyboardModeSupported( sessionId: ffi.sessionId, mode: kKeyMapMode)) { modeOnly = kKeyMapMode; } else if (bind.sessionIsKeyboardModeSupported( sessionId: ffi.sessionId, mode: kKeyLegacyMode)) { modeOnly = kKeyLegacyMode; } } for (InputModeMenu mode in modes) { if (modeOnly != null && mode.key != modeOnly) { continue; } else if (!bind.sessionIsKeyboardModeSupported( sessionId: ffi.sessionId, mode: mode.key)) { continue; } if (pi.isWayland && mode.key != kKeyMapMode) { continue; } var text = translate(mode.menu); if (mode.key == kKeyTranslateMode) { text = '$text beta'; } list.add(RdoMenuButton( child: Text(text), value: mode.key, groupValue: groupValue, onChanged: enabled ? onChanged : null, ffi: ffi, )); } return Column(children: list); }); } localKeyboardType() { final localPlatform = getLocalPlatformForKBLayoutType(pi.platform); final visible = localPlatform != ''; if (!visible) return Offstage(); final enabled = !ffi.ffiModel.viewOnly; return Column( children: [ Divider(), MenuButton( child: Text( '${translate('Local keyboard type')}: ${KBLayoutType.value}'), trailingIcon: const Icon(Icons.settings), ffi: ffi, onPressed: enabled ? () => showKBLayoutTypeChooser(localPlatform, ffi.dialogManager) : null, ) ], ); } inputSource() { final supportedInputSource = bind.mainSupportedInputSource(); if (supportedInputSource.isEmpty) return Offstage(); late final List supportedInputSourceList; try { supportedInputSourceList = jsonDecode(supportedInputSource); } catch (e) { debugPrint('Failed to decode $supportedInputSource, $e'); return; } if (supportedInputSourceList.length < 2) return Offstage(); final inputSource = stateGlobal.getInputSource(); final enabled = !ffi.ffiModel.viewOnly; final children = [Divider()]; children.addAll(supportedInputSourceList.map((e) { final d = e as List; return RdoMenuButton( child: Text(translate(d[1] as String)), value: d[0] as String, groupValue: inputSource, onChanged: enabled ? (v) async { if (v != null) { await stateGlobal.setInputSource(ffi.sessionId, v); await ffi.ffiModel.checkDesktopKeyboardMode(); await ffi.inputModel.updateKeyboardMode(); } } : null, ffi: ffi, ); })); return Column(children: children); } viewMode() { final ffiModel = ffi.ffiModel; final enabled = versionCmp(pi.version, '1.2.0') >= 0 && ffiModel.keyboard; return CkbMenuButton( value: ffiModel.viewOnly, onChanged: enabled ? (value) async { if (value == null) return; await bind.sessionToggleOption( sessionId: ffi.sessionId, value: kOptionToggleViewOnly); ffiModel.setViewOnly(id, value); } : null, ffi: ffi, child: Text(translate('View Mode'))); } mobileActions() { if (pi.platform != kPeerPlatformAndroid) return []; final enabled = versionCmp(pi.version, '1.2.6') >= 0; if (!enabled) return []; return [ Divider(), MenuButton( child: Text(translate('Back')), onPressed: () => ffi.inputModel.onMobileBack(), ffi: ffi), MenuButton( child: Text(translate('Home')), onPressed: () => ffi.inputModel.onMobileHome(), ffi: ffi), MenuButton( child: Text(translate('Apps')), onPressed: () => ffi.inputModel.onMobileApps(), ffi: ffi), MenuButton( child: Text(translate('Volume up')), onPressed: () => ffi.inputModel.onMobileVolumeUp(), ffi: ffi), MenuButton( child: Text(translate('Volume down')), onPressed: () => ffi.inputModel.onMobileVolumeDown(), ffi: ffi), MenuButton( child: Text(translate('Power')), onPressed: () => ffi.inputModel.onMobilePower(), ffi: ffi), ]; } } class _ChatMenu extends StatefulWidget { final String id; final FFI ffi; _ChatMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override State<_ChatMenu> createState() => _ChatMenuState(); } class _ChatMenuState extends State<_ChatMenu> { // Using in StatelessWidget got `Looking up a deactivated widget's ancestor is unsafe`. final chatButtonKey = GlobalKey(); @override Widget build(BuildContext context) { return _IconSubmenuButton( tooltip: 'Chat', key: chatButtonKey, svg: 'assets/chat.svg', ffi: widget.ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildrenGetter: () => [textChat(), voiceCall()]); } textChat() { return MenuButton( child: Text(translate('Text chat')), ffi: widget.ffi, onPressed: () { RenderBox? renderBox = chatButtonKey.currentContext?.findRenderObject() as RenderBox?; Offset? initPos; if (renderBox != null) { final pos = renderBox.localToGlobal(Offset.zero); initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight); } widget.ffi.chatModel.changeCurrentKey( MessageKey(widget.ffi.id, ChatModel.clientModeID)); widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos); }); } voiceCall() { return MenuButton( child: Text(translate('Voice call')), ffi: widget.ffi, onPressed: () => bind.sessionRequestVoiceCall(sessionId: widget.ffi.sessionId), ); } } class _VoiceCallMenu extends StatelessWidget { final String id; final FFI ffi; _VoiceCallMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override Widget build(BuildContext context) { menuChildrenGetter() { final audioInput = AudioInput(builder: (devices, currentDevice, setDevice) { return Column( children: devices .map((d) => RdoMenuButton( child: Container( child: Text( d, overflow: TextOverflow.ellipsis, ), constraints: BoxConstraints(maxWidth: 250), ), value: d, groupValue: currentDevice, onChanged: (v) { if (v != null) setDevice(v); }, ffi: ffi, )) .toList(), ); }); return [ audioInput, Divider(), MenuButton( child: Text(translate('End call')), onPressed: () => bind.sessionCloseVoiceCall(sessionId: ffi.sessionId), ffi: ffi, ), ]; } return Obx( () { switch (ffi.chatModel.voiceCallStatus.value) { case VoiceCallStatus.waitingForResponse: return buildCallWaiting(context); case VoiceCallStatus.connected: return _IconSubmenuButton( tooltip: 'Voice call', svg: 'assets/voice_call.svg', color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildrenGetter: menuChildrenGetter, ffi: ffi, ); default: return Offstage(); } }, ); } Widget buildCallWaiting(BuildContext context) { return _IconMenuButton( assetName: "assets/call_wait.svg", tooltip: "Waiting", onPressed: () => bind.sessionCloseVoiceCall(sessionId: ffi.sessionId), color: _ToolbarTheme.redColor, hoverColor: _ToolbarTheme.hoverRedColor, ); } } class _RecordMenu extends StatelessWidget { const _RecordMenu({Key? key}) : super(key: key); @override Widget build(BuildContext context) { var ffi = Provider.of(context); var recordingModel = Provider.of(context); final visible = (recordingModel.start || ffi.permissions['recording'] != false) && ffi.pi.currentDisplay != kAllDisplayValue; if (!visible) return Offstage(); return _IconMenuButton( assetName: 'assets/rec.svg', tooltip: recordingModel.start ? 'Stop session recording' : 'Start session recording', onPressed: () => recordingModel.toggle(), color: recordingModel.start ? _ToolbarTheme.redColor : _ToolbarTheme.blueColor, hoverColor: recordingModel.start ? _ToolbarTheme.hoverRedColor : _ToolbarTheme.hoverBlueColor, ); } } class _CloseMenu extends StatelessWidget { final String id; final FFI ffi; const _CloseMenu({Key? key, required this.id, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return _IconMenuButton( assetName: 'assets/close.svg', tooltip: 'Close', onPressed: () => closeConnection(id: id), color: _ToolbarTheme.redColor, hoverColor: _ToolbarTheme.hoverRedColor, ); } } class _IconMenuButton extends StatefulWidget { final String? assetName; final Widget? icon; final String tooltip; final Color color; final Color hoverColor; final VoidCallback? onPressed; final double? hMargin; final double? vMargin; final bool topLevel; final double? width; const _IconMenuButton({ Key? key, this.assetName, this.icon, required this.tooltip, required this.color, required this.hoverColor, required this.onPressed, this.hMargin, this.vMargin, this.topLevel = true, this.width, }) : super(key: key); @override State<_IconMenuButton> createState() => _IconMenuButtonState(); } class _IconMenuButtonState extends State<_IconMenuButton> { bool hover = false; @override Widget build(BuildContext context) { assert(widget.assetName != null || widget.icon != null); final icon = widget.icon ?? SvgPicture.asset( widget.assetName!, colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, ); var button = SizedBox( width: widget.width ?? _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, child: MenuItemButton( style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.transparent), padding: MaterialStatePropertyAll(EdgeInsets.zero), overlayColor: MaterialStatePropertyAll(Colors.transparent)), onHover: (value) => setState(() { hover = value; }), onPressed: widget.onPressed, child: Tooltip( message: translate(widget.tooltip), child: Material( type: MaterialType.transparency, child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_ToolbarTheme.iconRadius), color: hover ? widget.hoverColor : widget.color, ), child: icon)), )), ).marginSymmetric( horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin, vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin); button = Tooltip( message: widget.tooltip, child: button, ); if (widget.topLevel) { return MenuBar(children: [button]); } else { return button; } } } class _IconSubmenuButton extends StatefulWidget { final String tooltip; final String? svg; final Widget? icon; final Color color; final Color hoverColor; final List Function() menuChildrenGetter; final MenuStyle? menuStyle; final FFI? ffi; final double? width; _IconSubmenuButton({ Key? key, this.svg, this.icon, required this.tooltip, required this.color, required this.hoverColor, required this.menuChildrenGetter, this.ffi, this.menuStyle, this.width, }) : super(key: key); @override State<_IconSubmenuButton> createState() => _IconSubmenuButtonState(); } class _IconSubmenuButtonState extends State<_IconSubmenuButton> { bool hover = false; @override Widget build(BuildContext context) { assert(widget.svg != null || widget.icon != null); final icon = widget.icon ?? SvgPicture.asset( widget.svg!, colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, ); final button = SizedBox( width: widget.width ?? _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, child: SubmenuButton( menuStyle: widget.menuStyle ?? _ToolbarTheme.defaultMenuStyle(context), style: _ToolbarTheme.defaultMenuButtonStyle, onHover: (value) => setState(() { hover = value; }), child: Tooltip( message: translate(widget.tooltip), child: Material( type: MaterialType.transparency, child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_ToolbarTheme.iconRadius), color: hover ? widget.hoverColor : widget.color, ), child: icon))), menuChildren: widget .menuChildrenGetter() .map((e) => _buildPointerTrackWidget(e, widget.ffi)) .toList())); return MenuBar(children: [ button.marginSymmetric( horizontal: _ToolbarTheme.buttonHMargin, vertical: _ToolbarTheme.buttonVMargin) ]); } } class _SubmenuButton extends StatelessWidget { final List menuChildren; final Widget? child; final FFI ffi; const _SubmenuButton({ Key? key, required this.menuChildren, required this.child, required this.ffi, }) : super(key: key); @override Widget build(BuildContext context) { return SubmenuButton( key: key, child: child, menuChildren: menuChildren.map((e) => _buildPointerTrackWidget(e, ffi)).toList(), menuStyle: _ToolbarTheme.defaultMenuStyle(context), ); } } class MenuButton extends StatelessWidget { final VoidCallback? onPressed; final Widget? trailingIcon; final Widget? child; final FFI? ffi; MenuButton( {Key? key, this.onPressed, this.trailingIcon, required this.child, this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return MenuItemButton( key: key, onPressed: onPressed != null ? () { if (ffi != null) { _menuDismissCallback(ffi!); } onPressed?.call(); } : null, trailingIcon: trailingIcon, child: child); } } class CkbMenuButton extends StatelessWidget { final bool? value; final ValueChanged? onChanged; final Widget? child; final FFI? ffi; const CkbMenuButton( {Key? key, required this.value, required this.onChanged, required this.child, this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return CheckboxMenuButton( key: key, value: value, child: child, onChanged: onChanged != null ? (bool? value) { if (ffi != null) { _menuDismissCallback(ffi!); } onChanged?.call(value); } : null, ); } } class RdoMenuButton extends StatelessWidget { final T value; final T? groupValue; final ValueChanged? onChanged; final Widget? child; final FFI? ffi; const RdoMenuButton({ Key? key, required this.value, required this.groupValue, required this.child, this.ffi, this.onChanged, }) : super(key: key); @override Widget build(BuildContext context) { return RadioMenuButton( value: value, groupValue: groupValue, child: child, onChanged: onChanged != null ? (T? value) { if (ffi != null) { _menuDismissCallback(ffi!); } onChanged?.call(value); } : null, ); } } class _DraggableShowHide extends StatefulWidget { final SessionID sessionId; final RxDouble fractionX; final RxBool dragging; final ToolbarState toolbarState; final BorderRadius borderRadius; final Function(bool) setFullscreen; final Function() setMinimize; const _DraggableShowHide({ Key? key, required this.sessionId, required this.fractionX, required this.dragging, required this.toolbarState, required this.setFullscreen, required this.setMinimize, required this.borderRadius, }) : super(key: key); @override State<_DraggableShowHide> createState() => _DraggableShowHideState(); } class _DraggableShowHideState extends State<_DraggableShowHide> { Offset position = Offset.zero; Size size = Size.zero; double left = 0.0; double right = 1.0; RxBool get show => widget.toolbarState.show; @override initState() { super.initState(); final confLeft = double.tryParse( bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft)); if (confLeft == null) { bind.mainSetLocalOption( key: kOptionRemoteMenubarDragLeft, value: left.toString()); } else { left = confLeft; } final confRight = double.tryParse( bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight)); if (confRight == null) { bind.mainSetLocalOption( key: kOptionRemoteMenubarDragRight, value: right.toString()); } else { right = confRight; } } Widget _buildDraggable(BuildContext context) { return Draggable( axis: Axis.horizontal, child: Icon( Icons.drag_indicator, size: 20, color: MyTheme.color(context).drag_indicator, ), feedback: widget, onDragStarted: (() { final RenderObject? renderObj = context.findRenderObject(); if (renderObj != null) { final RenderBox renderBox = renderObj as RenderBox; size = renderBox.size; position = renderBox.localToGlobal(Offset.zero); } widget.dragging.value = true; }), onDragEnd: (details) { final mediaSize = MediaQueryData.fromView(View.of(context)).size; widget.fractionX.value += (details.offset.dx - position.dx) / (mediaSize.width - size.width); if (widget.fractionX.value < left) { widget.fractionX.value = left; } if (widget.fractionX.value > right) { widget.fractionX.value = right; } bind.sessionPeerOption( sessionId: widget.sessionId, name: 'remote-menubar-drag-x', value: widget.fractionX.value.toString(), ); widget.dragging.value = false; }, ); } @override Widget build(BuildContext context) { final ButtonStyle buttonStyle = ButtonStyle( minimumSize: MaterialStateProperty.all(const Size(0, 0)), padding: MaterialStateProperty.all(EdgeInsets.zero), ); final isFullscreen = stateGlobal.fullscreen; const double iconSize = 20; final child = Row( mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), Obx(() => TextButton( onPressed: () { widget.setFullscreen(!isFullscreen.value); }, child: Tooltip( message: translate( isFullscreen.isTrue ? 'Exit Fullscreen' : 'Fullscreen'), child: Icon( isFullscreen.isTrue ? Icons.fullscreen_exit : Icons.fullscreen, size: iconSize, ), ), )), if (!isMacOS) Obx(() => Offstage( offstage: isFullscreen.isFalse, child: TextButton( onPressed: () => widget.setMinimize(), child: Tooltip( message: translate('Minimize'), child: Icon( Icons.remove, size: iconSize, ), ), ), )), TextButton( onPressed: () => setState(() { widget.toolbarState.switchShow(widget.sessionId); }), child: Obx((() => Tooltip( message: translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), child: Icon( show.isTrue ? Icons.expand_less : Icons.expand_more, size: iconSize, ), ))), ), ], ); return TextButtonTheme( data: TextButtonThemeData(style: buttonStyle), child: Container( decoration: BoxDecoration( color: Theme.of(context) .menuBarTheme .style ?.backgroundColor ?.resolve(MaterialState.values.toSet()), border: Border.all( color: _ToolbarTheme.borderColor(context), width: 1, ), borderRadius: widget.borderRadius, ), child: SizedBox( height: 20, child: child, ), ), ); } } class InputModeMenu { final String key; final String menu; InputModeMenu({required this.key, required this.menu}); } _menuDismissCallback(FFI ffi) => ffi.inputModel.refreshMousePos(); Widget _buildPointerTrackWidget(Widget child, FFI? ffi) { return Listener( onPointerHover: (PointerHoverEvent e) => { if (ffi != null) {ffi.inputModel.lastMousePos = e.position} }, child: MouseRegion( child: child, ), ); }