From b2c05908986defa93232e7f613e5e575fb9e31df Mon Sep 17 00:00:00 2001 From: 21pages Date: Wed, 12 Apr 2023 09:41:13 +0800 Subject: [PATCH] merge mobile/desktop remote toobar code Signed-off-by: 21pages --- flutter/lib/common.dart | 26 +- flutter/lib/common/shared_state.dart | 20 + flutter/lib/common/widgets/dialog.dart | 224 ++++- flutter/lib/common/widgets/toolbar.dart | 449 ++++++++++ flutter/lib/desktop/pages/remote_page.dart | 22 +- .../lib/desktop/widgets/remote_toolbar.dart | 830 +++--------------- flutter/lib/mobile/pages/remote_page.dart | 298 ++----- flutter/lib/mobile/pages/settings_page.dart | 12 +- 8 files changed, 882 insertions(+), 999 deletions(-) create mode 100644 flutter/lib/common/widgets/toolbar.dart diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 49cbd6091..daffe261f 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -43,6 +43,7 @@ final isIOS = Platform.isIOS; final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; var isWeb = false; var isWebDesktop = false; +var isMobile = isAndroid || isIOS; var version = ""; int androidVersion = 0; @@ -1158,40 +1159,19 @@ class AndroidPermissionManager { // Used only for mobile, pages remote, settings, dialog // TODO remove argument contentPadding, it’s not used, getToggle() has not RadioListTile getRadio( - String name, T toValue, T curValue, void Function(T?) onChange, + Widget title, T toValue, T curValue, ValueChanged? onChange, {EdgeInsetsGeometry? contentPadding}) { return RadioListTile( contentPadding: contentPadding ?? EdgeInsets.zero, visualDensity: VisualDensity.compact, controlAffinity: ListTileControlAffinity.trailing, - title: Text(translate(name)), + title: title, value: toValue, groupValue: curValue, onChanged: onChange, ); } -// TODO move this to mobile/widgets. -// Used only for mobile, pages remote, settings, dialog -CheckboxListTile getToggle( - String id, void Function(void Function()) setState, option, name, - {FFI? ffi}) { - final opt = bind.sessionGetToggleOptionSync(id: id, arg: option); - return CheckboxListTile( - contentPadding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - value: opt, - onChanged: (v) { - setState(() { - bind.sessionToggleOption(id: id, value: option); - }); - if (option == "show-quality-monitor") { - (ffi ?? gFFI).qualityMonitorModel.checkShowQualityMonitor(id); - } - }, - title: Text(translate(name))); -} - /// find ffi, tag is Remote ID /// for session specific usage FFI ffi(String? tag) { diff --git a/flutter/lib/common/shared_state.dart b/flutter/lib/common/shared_state.dart index bc1a562b9..4659a01ce 100644 --- a/flutter/lib/common/shared_state.dart +++ b/flutter/lib/common/shared_state.dart @@ -261,3 +261,23 @@ class PeerStringOption { static RxString find(String id, String opt) => Get.find(tag: tag(id, opt)); } + +initSharedStates(String id) { + PrivacyModeState.init(id); + BlockInputState.init(id); + CurrentDisplayState.init(id); + KeyboardEnabledState.init(id); + ShowRemoteCursorState.init(id); + RemoteCursorMovedState.init(id); + PeerBoolOption.init(id, 'zoom-cursor', () => false); +} + +removeSharedStates(String id) { + PrivacyModeState.delete(id); + BlockInputState.delete(id); + CurrentDisplayState.delete(id); + ShowRemoteCursorState.delete(id); + KeyboardEnabledState.delete(id); + RemoteCursorMovedState.delete(id); + PeerBoolOption.delete(id, 'zoom-cursor'); +} diff --git a/flutter/lib/common/widgets/dialog.dart b/flutter/lib/common/widgets/dialog.dart index cf1ced92f..7228bb585 100644 --- a/flutter/lib/common/widgets/dialog.dart +++ b/flutter/lib/common/widgets/dialog.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:debounce_throttle/debounce_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common/shared_state.dart'; import 'package:get/get.dart'; import '../../common.dart'; @@ -879,7 +881,9 @@ void showRestartRemoteDevice( await dialogManager.show((setState, close) => CustomAlertDialog( title: Row(children: [ Icon(Icons.warning_rounded, color: Colors.redAccent, size: 28), - Text(translate("Restart Remote Device")).paddingOnly(left: 10), + Flexible( + child: Text(translate("Restart Remote Device")) + .paddingOnly(left: 10)), ]), content: Text( "${translate('Are you sure you want to restart')} \n${pi.username}@${pi.hostname}($id) ?"), @@ -1047,3 +1051,221 @@ showSetOSAccount( ); }); } + +showAuditDialog(String id, dialogManager) async { + final controller = TextEditingController(); + dialogManager.show((setState, close) { + submit() { + var text = controller.text.trim(); + if (text != '') { + bind.sessionSendNote(id: id, note: text); + } + close(); + } + + late final focusNode = FocusNode( + onKey: (FocusNode node, RawKeyEvent evt) { + if (evt.logicalKey.keyLabel == 'Enter') { + if (evt is RawKeyDownEvent) { + int pos = controller.selection.base.offset; + controller.text = + '${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}'; + controller.selection = + TextSelection.fromPosition(TextPosition(offset: pos + 1)); + } + return KeyEventResult.handled; + } + if (evt.logicalKey.keyLabel == 'Esc') { + if (evt is RawKeyDownEvent) { + close(); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + }, + ); + + return CustomAlertDialog( + title: Text(translate('Note')), + content: SizedBox( + width: 250, + height: 120, + child: TextField( + autofocus: true, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration.collapsed( + hintText: 'input note here', + ), + maxLines: null, + maxLength: 256, + controller: controller, + focusNode: focusNode, + )), + actions: [ + dialogButton('Cancel', onPressed: close, isOutline: true), + dialogButton('OK', onPressed: submit) + ], + onSubmit: submit, + onCancel: close, + ); + }); +} + +void showConfirmSwitchSidesDialog( + String id, OverlayDialogManager dialogManager) async { + dialogManager.show((setState, close) { + submit() async { + await bind.sessionSwitchSides(id: id); + closeConnection(id: id); + } + + return CustomAlertDialog( + content: msgboxContent('info', 'Switch Sides', + 'Please confirm if you want to share your desktop?'), + actions: [ + dialogButton('Cancel', onPressed: close, isOutline: true), + dialogButton('OK', onPressed: submit), + ], + onSubmit: submit, + onCancel: close, + ); + }); +} + +customImageQualityDialog(String id, FFI ffi) async { + double qualityInitValue = 50; + double fpsInitValue = 30; + bool qualitySet = false; + bool fpsSet = false; + setCustomValues({double? quality, double? fps}) async { + if (quality != null) { + qualitySet = true; + await bind.sessionSetCustomImageQuality(id: id, value: quality.toInt()); + } + if (fps != null) { + fpsSet = true; + await bind.sessionSetCustomFps(id: id, fps: fps.toInt()); + } + if (!qualitySet) { + qualitySet = true; + await bind.sessionSetCustomImageQuality( + id: id, value: qualityInitValue.toInt()); + } + if (!fpsSet) { + fpsSet = true; + await bind.sessionSetCustomFps(id: id, fps: fpsInitValue.toInt()); + } + } + + final btnClose = dialogButton('Close', onPressed: () async { + await setCustomValues(); + ffi.dialogManager.dismissAll(); + }); + + // quality + final quality = await bind.sessionGetCustomImageQuality(id: id); + qualityInitValue = + quality != null && quality.isNotEmpty ? quality[0].toDouble() : 50.0; + const qualityMinValue = 10.0; + const qualityMaxValue = 100.0; + if (qualityInitValue < qualityMinValue) { + qualityInitValue = qualityMinValue; + } + if (qualityInitValue > qualityMaxValue) { + qualityInitValue = qualityMaxValue; + } + final RxDouble qualitySliderValue = RxDouble(qualityInitValue); + final debouncerQuality = Debouncer( + Duration(milliseconds: 1000), + onChanged: (double v) { + setCustomValues(quality: v); + }, + initialValue: qualityInitValue, + ); + final qualitySlider = Obx(() => Row( + children: [ + Expanded( + flex: 3, + child: Slider( + value: qualitySliderValue.value, + min: qualityMinValue, + max: qualityMaxValue, + divisions: 18, + onChanged: (double value) { + qualitySliderValue.value = value; + debouncerQuality.value = value; + }, + )), + Expanded( + flex: 1, + child: Text( + '${qualitySliderValue.value.round()}%', + style: const TextStyle(fontSize: 15), + )), + Expanded( + flex: 2, + child: Text( + translate('Bitrate'), + style: const TextStyle(fontSize: 15), + )), + ], + )); + // fps + final fpsOption = await bind.sessionGetOption(id: id, arg: 'custom-fps'); + fpsInitValue = fpsOption == null ? 30 : double.tryParse(fpsOption) ?? 30; + if (fpsInitValue < 5 || fpsInitValue > 120) { + fpsInitValue = 30; + } + final RxDouble fpsSliderValue = RxDouble(fpsInitValue); + final debouncerFps = Debouncer( + Duration(milliseconds: 1000), + onChanged: (double v) { + setCustomValues(fps: v); + }, + initialValue: qualityInitValue, + ); + bool? direct; + try { + direct = + ConnectionTypeState.find(id).direct.value == ConnectionType.strDirect; + } catch (_) {} + final fpsSlider = Offstage( + offstage: (await bind.mainIsUsingPublicServer() && direct != true) || + version_cmp(ffi.ffiModel.pi.version, '1.2.0') < 0, + child: Row( + children: [ + Expanded( + flex: 3, + child: Obx((() => Slider( + value: fpsSliderValue.value, + min: 5, + max: 120, + divisions: 23, + onChanged: (double value) { + fpsSliderValue.value = value; + debouncerFps.value = value; + }, + )))), + Expanded( + flex: 1, + child: Obx(() => Text( + '${fpsSliderValue.value.round()}', + style: const TextStyle(fontSize: 15), + ))), + Expanded( + flex: 2, + child: Text( + translate('FPS'), + style: const TextStyle(fontSize: 15), + )) + ], + ), + ); + + final content = Column( + children: [qualitySlider, fpsSlider], + ); + msgBoxCommon(ffi.dialogManager, 'Custom Image Quality', content, [btnClose]); +} diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart new file mode 100644 index 000000000..abf12de30 --- /dev/null +++ b/flutter/lib/common/widgets/toolbar.dart @@ -0,0 +1,449 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/common/shared_state.dart'; +import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; + +class TTextMenu { + final Widget child; + final VoidCallback onPressed; + Widget? trailingIcon; + bool divider; + TTextMenu( + {required this.child, + required this.onPressed, + this.trailingIcon, + this.divider = false}); +} + +class TRadioMenu { + final Widget child; + final T value; + final T groupValue; + final ValueChanged? onChanged; + + TRadioMenu( + {required this.child, + required this.value, + required this.groupValue, + required this.onChanged}); +} + +class TToggleMenu { + final Widget child; + final bool value; + final ValueChanged? onChanged; + TToggleMenu( + {required this.child, required this.value, required this.onChanged}); +} + +List toolbarControls(BuildContext context, String id, FFI ffi) { + final ffiModel = ffi.ffiModel; + final pi = ffiModel.pi; + final perms = ffiModel.permissions; + + List v = []; + // elevation + if (ffi.elevationModel.showRequestMenu) { + v.add( + TTextMenu( + child: Text(translate('Request Elevation')), + onPressed: () => showRequestElevationDialog(id, ffi.dialogManager)), + ); + } + // osAccount / osPassword + v.add( + TTextMenu( + child: Row(children: [ + Text(translate(pi.is_headless ? 'OS Account' : 'OS Password')), + Offstage( + offstage: isDesktop, + child: + Icon(Icons.edit, color: MyTheme.accent).marginOnly(left: 12)) + ]), + trailingIcon: Transform.scale(scale: 0.8, child: Icon(Icons.edit)), + onPressed: () => pi.is_headless + ? showSetOSAccount(id, ffi.dialogManager) + : showSetOSPassword(id, false, ffi.dialogManager)), + ); + // paste + if (isMobile && perms['keyboard'] != false && perms['clipboard'] != false) { + v.add(TTextMenu( + child: Text(translate('Paste')), + onPressed: () async { + ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null && data.text != null) { + bind.sessionInputString(id: id, value: data.text ?? ""); + } + })); + } + // reset canvas + if (isMobile) { + v.add(TTextMenu( + child: Text(translate('Reset canvas')), + onPressed: () => ffi.cursorModel.reset())); + } + // transferFile + if (isDesktop) { + v.add( + TTextMenu( + child: Text(translate('Transfer File')), + onPressed: () => connect(context, id, isFileTransfer: true)), + ); + } + // tcpTunneling + if (isDesktop) { + v.add( + TTextMenu( + child: Text(translate('TCP Tunneling')), + onPressed: () => connect(context, id, isTcpTunneling: true)), + ); + } + // note + if (bind.sessionGetAuditServerSync(id: id, typ: "conn").isNotEmpty) { + v.add( + TTextMenu( + child: Text(translate('Note')), + onPressed: () => showAuditDialog(id, ffi.dialogManager)), + ); + } + // divider + if (isDesktop) { + v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true)); + } + // ctrlAltDel + if (!ffiModel.viewOnly && + ffiModel.keyboard && + (pi.platform == kPeerPlatformLinux || pi.sasEnabled)) { + v.add( + TTextMenu( + child: Text('${translate("Insert")} Ctrl + Alt + Del'), + onPressed: () => bind.sessionCtrlAltDel(id: id)), + ); + } + // restart + if (perms['restart'] != false && + (pi.platform == kPeerPlatformLinux || + pi.platform == kPeerPlatformWindows || + pi.platform == kPeerPlatformMacOS)) { + v.add( + TTextMenu( + child: Text(translate('Restart Remote Device')), + onPressed: () => showRestartRemoteDevice(pi, id, ffi.dialogManager)), + ); + } + // insertLock + if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) { + v.add( + TTextMenu( + child: Text(translate('Insert Lock')), + onPressed: () => bind.sessionLockScreen(id: id)), + ); + } + // blockUserInput + if (ffi.ffiModel.keyboard && + pi.platform == kPeerPlatformWindows) // privacy-mode != true ?? + { + v.add(TTextMenu( + child: Obx(() => Text(translate( + '${BlockInputState.find(id).value ? 'Unb' : 'B'}lock user input'))), + onPressed: () { + RxBool blockInput = BlockInputState.find(id); + bind.sessionToggleOption( + id: id, value: '${blockInput.value ? 'un' : ''}block-input'); + blockInput.value = !blockInput.value; + })); + } + // switchSides + if (isDesktop && + ffiModel.keyboard && + pi.platform != kPeerPlatformAndroid && + pi.platform != kPeerPlatformMacOS && + version_cmp(pi.version, '1.2.0') >= 0) { + v.add(TTextMenu( + child: Text(translate('Switch Sides')), + onPressed: () => showConfirmSwitchSidesDialog(id, ffi.dialogManager))); + } + // refresh + if (pi.version.isNotEmpty) { + v.add(TTextMenu( + child: Text(translate('Refresh')), + onPressed: () => bind.sessionRefresh(id: id))); + } + // record + var codecFormat = ffi.qualityMonitorModel.data.codecFormat; + if (!isDesktop && + (ffi.recordingModel.start || + (perms["recording"] != false && + (codecFormat == "VP8" || codecFormat == "VP9")))) { + v.add(TTextMenu( + child: Row( + children: [ + Text(translate(ffi.recordingModel.start + ? 'Stop session recording' + : 'Start session recording')), + Padding( + padding: EdgeInsets.only(left: 12), + child: Icon( + ffi.recordingModel.start + ? Icons.pause_circle_filled + : Icons.videocam_outlined, + color: MyTheme.accent), + ) + ], + ), + onPressed: () => ffi.recordingModel.toggle())); + } + return v; +} + +Future>> toolbarViewStyle( + BuildContext context, String id, FFI ffi) async { + final groupValue = await bind.sessionGetViewStyle(id: id) ?? ''; + void onChanged(String? value) async { + if (value == null) return; + bind + .sessionSetViewStyle(id: id, value: value) + .then((_) => ffi.canvasModel.updateViewStyle()); + } + + return [ + TRadioMenu( + child: Text(translate('Scale original')), + value: kRemoteViewStyleOriginal, + groupValue: groupValue, + onChanged: onChanged), + TRadioMenu( + child: Text(translate('Scale adaptive')), + value: kRemoteViewStyleAdaptive, + groupValue: groupValue, + onChanged: onChanged) + ]; +} + +Future>> toolbarImageQuality( + BuildContext context, String id, FFI ffi) async { + final groupValue = await bind.sessionGetImageQuality(id: id) ?? ''; + onChanged(String? value) async { + if (value == null) return; + await bind.sessionSetImageQuality(id: id, value: value); + } + + return [ + TRadioMenu( + child: Text(translate('Good image quality')), + value: kRemoteImageQualityBest, + groupValue: groupValue, + onChanged: onChanged), + TRadioMenu( + child: Text(translate('Balanced')), + value: kRemoteImageQualityBalanced, + groupValue: groupValue, + onChanged: onChanged), + TRadioMenu( + child: Text(translate('Optimize reaction time')), + value: kRemoteImageQualityLow, + groupValue: groupValue, + onChanged: onChanged), + TRadioMenu( + child: Text(translate('Custom')), + value: kRemoteImageQualityCustom, + groupValue: groupValue, + onChanged: (value) { + onChanged(value); + customImageQualityDialog(id, ffi); + }, + ), + ]; +} + +Future>> toolbarCodec( + BuildContext context, String id, FFI ffi) async { + final alternativeCodecs = await bind.sessionAlternativeCodecs(id: id); + final groupValue = + await bind.sessionGetOption(id: id, arg: 'codec-preference') ?? ''; + final List codecs = []; + try { + final Map codecsJson = jsonDecode(alternativeCodecs); + final vp8 = codecsJson['vp8'] ?? false; + final h264 = codecsJson['h264'] ?? false; + final h265 = codecsJson['h265'] ?? false; + codecs.add(vp8); + codecs.add(h264); + codecs.add(h265); + } catch (e) { + debugPrint("Show Codec Preference err=$e"); + } + final visible = codecs.length == 3 && (codecs[0] || codecs[1] || codecs[2]); + if (!visible) return []; + onChanged(String? value) async { + if (value == null) return; + await bind.sessionPeerOption( + id: id, name: 'codec-preference', value: value); + bind.sessionChangePreferCodec(id: id); + } + + TRadioMenu radio(String label, String value, bool enabled) { + return TRadioMenu( + child: Text(translate(label)), + value: value, + groupValue: groupValue, + onChanged: enabled ? onChanged : null); + } + + return [ + radio('Auto', 'auto', true), + if (isDesktop || codecs[0]) radio('VP8', 'vp8', codecs[0]), + radio('VP9', 'vp9', true), + if (isDesktop || codecs[1]) radio('H264', 'h264', codecs[1]), + if (isDesktop || codecs[2]) radio('H265', 'h265', codecs[2]), + ]; +} + +Future> toolbarDisplayToggle( + BuildContext context, String id, FFI ffi) async { + List v = []; + final ffiModel = ffi.ffiModel; + final pi = ffiModel.pi; + final perms = ffiModel.permissions; + + // show remote cursor + if (pi.platform != kPeerPlatformAndroid && + !ffi.canvasModel.cursorEmbedded && + !pi.is_wayland) { + final state = ShowRemoteCursorState.find(id); + final enabled = !ffiModel.viewOnly; + final option = 'show-remote-cursor'; + v.add(TToggleMenu( + child: Text(translate('Show remote cursor')), + value: state.value, + onChanged: enabled + ? (value) async { + if (value == null) return; + await bind.sessionToggleOption(id: id, value: option); + state.value = + bind.sessionGetToggleOptionSync(id: id, arg: option); + } + : null)); + } + // zoom cursor + final viewStyle = await bind.sessionGetViewStyle(id: id) ?? ''; + if (!isMobile && + pi.platform != kPeerPlatformAndroid && + viewStyle != kRemoteViewStyleOriginal) { + final option = 'zoom-cursor'; + final peerState = PeerBoolOption.find(id, option); + v.add(TToggleMenu( + child: Text(translate('Zoom cursor')), + value: peerState.value, + onChanged: (value) async { + if (value == null) return; + await bind.sessionToggleOption(id: id, value: option); + peerState.value = bind.sessionGetToggleOptionSync(id: id, arg: option); + }, + )); + } + // show quality monitor + final option = 'show-quality-monitor'; + v.add(TToggleMenu( + value: bind.sessionGetToggleOptionSync(id: id, arg: option), + onChanged: (value) async { + if (value == null) return; + await bind.sessionToggleOption(id: id, value: option); + ffi.qualityMonitorModel.checkShowQualityMonitor(id); + }, + child: Text(translate('Show quality monitor')))); + // mute + if (perms['audio'] != false) { + final option = 'disable-audio'; + final value = bind.sessionGetToggleOptionSync(id: id, arg: option); + v.add(TToggleMenu( + value: value, + onChanged: (value) { + if (value == null) return; + bind.sessionToggleOption(id: id, value: option); + }, + child: Text(translate('Mute')))); + } + // file copy and paste + if (Platform.isWindows && + pi.platform == kPeerPlatformWindows && + perms['file'] != false) { + final option = 'enable-file-transfer'; + final value = bind.sessionGetToggleOptionSync(id: id, arg: option); + v.add(TToggleMenu( + value: value, + onChanged: (value) { + if (value == null) return; + bind.sessionToggleOption(id: id, value: option); + }, + child: Text(translate('Allow file copy and paste')))); + } + // disable clipboard + if (ffiModel.keyboard && perms['clipboard'] != false) { + final enabled = !ffiModel.viewOnly; + final option = 'disable-clipboard'; + var value = bind.sessionGetToggleOptionSync(id: id, arg: option); + if (ffiModel.viewOnly) value = true; + v.add(TToggleMenu( + value: value, + onChanged: enabled + ? (value) { + if (value == null) return; + bind.sessionToggleOption(id: id, value: option); + } + : null, + child: Text(translate('Disable clipboard')))); + } + // lock after session end + if (ffiModel.keyboard) { + final option = 'lock-after-session-end'; + final value = bind.sessionGetToggleOptionSync(id: id, arg: option); + v.add(TToggleMenu( + value: value, + onChanged: (value) { + if (value == null) return; + bind.sessionToggleOption(id: id, value: option); + }, + child: Text(translate('Lock after session end')))); + } + // privacy mode + if (ffiModel.keyboard && pi.features.privacyMode) { + final option = 'privacy-mode'; + final rxValue = PrivacyModeState.find(id); + v.add(TToggleMenu( + value: rxValue.value, + onChanged: (value) { + if (value == null) return; + if (ffiModel.pi.currentDisplay != 0) { + msgBox(id, 'custom-nook-nocancel-hasclose', 'info', + 'Please switch to Display 1 first', '', ffi.dialogManager); + return; + } + bind.sessionToggleOption(id: id, value: option); + }, + child: Text(translate('Privacy mode')))); + } + // swap key + if (ffiModel.keyboard && + ((Platform.isMacOS && pi.platform != kPeerPlatformMacOS) || + (!Platform.isMacOS && pi.platform == kPeerPlatformMacOS))) { + final option = 'allow_swap_key'; + final value = bind.sessionGetToggleOptionSync(id: id, arg: option); + v.add(TToggleMenu( + value: value, + onChanged: (value) { + if (value == null) return; + bind.sessionToggleOption(id: id, value: option); + }, + child: Text(translate('Swap control-command key')))); + } + return v; +} diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 312f9bbed..bd026874b 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -77,15 +77,8 @@ class _RemotePageState extends State late FFI _ffi; void _initStates(String id) { - PrivacyModeState.init(id); - BlockInputState.init(id); - CurrentDisplayState.init(id); - KeyboardEnabledState.init(id); - ShowRemoteCursorState.init(id); - RemoteCursorMovedState.init(id); - final optZoomCursor = 'zoom-cursor'; - PeerBoolOption.init(id, optZoomCursor, () => false); - _zoomCursor = PeerBoolOption.find(id, optZoomCursor); + initSharedStates(id); + _zoomCursor = PeerBoolOption.find(id, 'zoom-cursor'); _showRemoteCursor = ShowRemoteCursorState.find(id); _keyboardEnabled = KeyboardEnabledState.find(id); _remoteCursorMoved = RemoteCursorMovedState.find(id); @@ -93,15 +86,6 @@ class _RemotePageState extends State _textureId = RxInt(-1); } - void _removeStates(String id) { - PrivacyModeState.delete(id); - BlockInputState.delete(id); - CurrentDisplayState.delete(id); - ShowRemoteCursorState.delete(id); - KeyboardEnabledState.delete(id); - RemoteCursorMovedState.delete(id); - } - @override void initState() { super.initState(); @@ -217,7 +201,7 @@ class _RemotePageState extends State } Get.delete(tag: widget.id); super.dispose(); - _removeStates(widget.id); + removeSharedStates(widget.id); } Widget buildBody(BuildContext context) { diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 9627b107b..f8a38c830 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/consts.dart'; @@ -31,7 +32,6 @@ class MenubarState { final kStoreKey = 'remoteMenubarState'; late RxBool show; late RxBool _pin; - RxString viewStyle = RxString(kRemoteViewStyleOriginal); MenubarState() { final s = bind.getLocalFlutterConfig(k: kStoreKey); @@ -456,7 +456,7 @@ class _RemoteMenubarState extends State { return Theme.of(context).copyWith( menuButtonTheme: MenuButtonThemeData( style: ButtonStyle( - minimumSize: MaterialStatePropertyAll(Size(64, 36)), + minimumSize: MaterialStatePropertyAll(Size(64, 32)), textStyle: MaterialStatePropertyAll( TextStyle(fontWeight: FontWeight.normal), ), @@ -637,229 +637,17 @@ class _ControlMenu extends StatelessWidget { color: _MenubarTheme.blueColor, hoverColor: _MenubarTheme.hoverBlueColor, ffi: ffi, - menuChildren: [ - requestElevation(), - ffi.ffiModel.pi.is_headless ? osAccount() : osPassword(), - transferFile(context), - tcpTunneling(context), - note(), - Divider(), - ctrlAltDel(), - restart(), - insertLock(), - blockUserInput(), - switchSides(), - refresh(), - ]); - } - - requestElevation() { - final visible = ffi.elevationModel.showRequestMenu; - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text(translate('Request Elevation')), - ffi: ffi, - onPressed: () => showRequestElevationDialog(id, ffi.dialogManager)); - } - - osAccount() { - return _MenuItemButton( - child: Text(translate('OS Account')), - trailingIcon: Transform.scale(scale: 0.8, child: Icon(Icons.edit)), - ffi: ffi, - onPressed: () => showSetOSAccount(id, ffi.dialogManager)); - } - - osPassword() { - return _MenuItemButton( - child: Text(translate('OS Password')), - trailingIcon: Transform.scale(scale: 0.8, child: Icon(Icons.edit)), - ffi: ffi, - onPressed: () => showSetOSPassword(id, false, ffi.dialogManager)); - } - - transferFile(BuildContext context) { - return _MenuItemButton( - child: Text(translate('Transfer File')), - ffi: ffi, - onPressed: () => connect(context, id, isFileTransfer: true)); - } - - tcpTunneling(BuildContext context) { - return _MenuItemButton( - child: Text(translate('TCP Tunneling')), - ffi: ffi, - onPressed: () => connect(context, id, isTcpTunneling: true)); - } - - note() { - final auditServer = bind.sessionGetAuditServerSync(id: id, typ: "conn"); - final visible = auditServer.isNotEmpty; - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text(translate('Note')), - ffi: ffi, - onPressed: () => _showAuditDialog(id, ffi.dialogManager), - ); - } - - _showAuditDialog(String id, dialogManager) async { - final controller = TextEditingController(); - dialogManager.show((setState, close) { - submit() { - var text = controller.text.trim(); - if (text != '') { - bind.sessionSendNote(id: id, note: text); - } - close(); - } - - late final focusNode = FocusNode( - onKey: (FocusNode node, RawKeyEvent evt) { - if (evt.logicalKey.keyLabel == 'Enter') { - if (evt is RawKeyDownEvent) { - int pos = controller.selection.base.offset; - controller.text = - '${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}'; - controller.selection = - TextSelection.fromPosition(TextPosition(offset: pos + 1)); - } - return KeyEventResult.handled; - } - if (evt.logicalKey.keyLabel == 'Esc') { - if (evt is RawKeyDownEvent) { - close(); - } - return KeyEventResult.handled; + menuChildren: toolbarControls(context, id, ffi).map((e) { + if (e.divider) { + return Divider(); } else { - return KeyEventResult.ignored; + return _MenuItemButton( + child: e.child, + onPressed: e.onPressed, + ffi: ffi, + trailingIcon: e.trailingIcon); } - }, - ); - - return CustomAlertDialog( - title: Text(translate('Note')), - content: SizedBox( - width: 250, - height: 120, - child: TextField( - autofocus: true, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - decoration: const InputDecoration.collapsed( - hintText: 'input note here', - ), - maxLines: null, - maxLength: 256, - controller: controller, - focusNode: focusNode, - )), - actions: [ - dialogButton('Cancel', onPressed: close, isOutline: true), - dialogButton('OK', onPressed: submit) - ], - onSubmit: submit, - onCancel: close, - ); - }); - } - - ctrlAltDel() { - final viewOnly = ffi.ffiModel.viewOnly; - final pi = ffi.ffiModel.pi; - final visible = !viewOnly && - ffi.ffiModel.keyboard && - (pi.platform == kPeerPlatformLinux || pi.sasEnabled); - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text('${translate("Insert")} Ctrl + Alt + Del'), - ffi: ffi, - onPressed: () => bind.sessionCtrlAltDel(id: id)); - } - - restart() { - final perms = ffi.ffiModel.permissions; - final pi = ffi.ffiModel.pi; - final visible = perms['restart'] != false && - (pi.platform == kPeerPlatformLinux || - pi.platform == kPeerPlatformWindows || - pi.platform == kPeerPlatformMacOS); - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text(translate('Restart Remote Device')), - ffi: ffi, - onPressed: () => showRestartRemoteDevice(pi, id, ffi.dialogManager)); - } - - insertLock() { - final viewOnly = ffi.ffiModel.viewOnly; - final visible = !viewOnly && ffi.ffiModel.keyboard; - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text(translate('Insert Lock')), - ffi: ffi, - onPressed: () => bind.sessionLockScreen(id: id)); - } - - blockUserInput() { - final pi = ffi.ffiModel.pi; - final visible = - ffi.ffiModel.keyboard && pi.platform == kPeerPlatformWindows; - if (!visible) return Offstage(); - return _MenuItemButton( - child: Obx(() => Text(translate( - '${BlockInputState.find(id).value ? 'Unb' : 'B'}lock user input'))), - ffi: ffi, - onPressed: () { - RxBool blockInput = BlockInputState.find(id); - bind.sessionToggleOption( - id: id, value: '${blockInput.value ? 'un' : ''}block-input'); - blockInput.value = !blockInput.value; - }); - } - - switchSides() { - final pi = ffi.ffiModel.pi; - final visible = ffi.ffiModel.keyboard && - pi.platform != kPeerPlatformAndroid && - pi.platform != kPeerPlatformMacOS && - version_cmp(pi.version, '1.2.0') >= 0; - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text(translate('Switch Sides')), - ffi: ffi, - onPressed: () => _showConfirmSwitchSidesDialog(id, ffi.dialogManager)); - } - - void _showConfirmSwitchSidesDialog( - String id, OverlayDialogManager dialogManager) async { - dialogManager.show((setState, close) { - submit() async { - await bind.sessionSwitchSides(id: id); - closeConnection(id: id); - } - - return CustomAlertDialog( - content: msgboxContent('info', 'Switch Sides', - 'Please confirm if you want to share your desktop?'), - actions: [ - dialogButton('Cancel', onPressed: close, isOutline: true), - dialogButton('OK', onPressed: submit), - ], - onSubmit: submit, - onCancel: close, - ); - }); - } - - refresh() { - final pi = ffi.ffiModel.pi; - final visible = pi.version.isNotEmpty; - if (!visible) return Offstage(); - return _MenuItemButton( - child: Text(translate('Refresh')), - ffi: ffi, - onPressed: () => bind.sessionRefresh(id: id)); + }).toList()); } } @@ -891,6 +679,8 @@ class _DisplayMenuState extends State<_DisplayMenu> { PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; + FFI get ffi => widget.ffi; + String get id => widget.id; @override Widget build(BuildContext context) { @@ -909,30 +699,26 @@ class _DisplayMenuState extends State<_DisplayMenu> { codec(), resolutions(), Divider(), - showRemoteCursor(), - zoomCursor(), - showQualityMonitor(), - mute(), - fileCopyAndPaste(), - disableClipboard(), - lockAfterSessionEnd(), - privacyMode(), - swapKey(), + toggles(), ]); } adjustWindow() { - final visible = _isWindowCanBeAdjusted(); - if (!visible) return Offstage(); - return Column( - children: [ - _MenuItemButton( - child: Text(translate('Adjust Window')), - onPressed: _doAdjustWindow, - ffi: widget.ffi), - Divider(), - ], - ); + return futureBuilder( + future: _isWindowCanBeAdjusted(), + hasData: (data) { + final visible = data as bool; + if (!visible) return Offstage(); + return Column( + children: [ + _MenuItemButton( + child: Text(translate('Adjust Window')), + onPressed: _doAdjustWindow, + ffi: widget.ffi), + Divider(), + ], + ); + }); } _doAdjustWindow() async { @@ -1004,8 +790,9 @@ class _DisplayMenuState extends State<_DisplayMenu> { } } - _isWindowCanBeAdjusted() { - if (widget.state.viewStyle.value != kRemoteViewStyleOriginal) { + Future _isWindowCanBeAdjusted() async { + final viewStyle = await bind.sessionGetViewStyle(id: widget.id) ?? ''; + if (viewStyle != kRemoteViewStyleOriginal) { return false; } final remoteCount = RemoteCountState.find().value; @@ -1035,47 +822,34 @@ class _DisplayMenuState extends State<_DisplayMenu> { } viewStyle() { - return futureBuilder(future: () async { - final viewStyle = await bind.sessionGetViewStyle(id: widget.id) ?? ''; - widget.state.viewStyle.value = viewStyle; - return viewStyle; - }(), hasData: (data) { - final groupValue = data as String; - onChanged(String? value) async { - if (value == null) return; - await bind.sessionSetViewStyle(id: widget.id, value: value); - widget.state.viewStyle.value = value; - widget.ffi.canvasModel.updateViewStyle(); - } - - return Column(children: [ - _RadioMenuButton( - child: Text(translate('Scale original')), - value: kRemoteViewStyleOriginal, - groupValue: groupValue, - onChanged: onChanged, - ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('Scale adaptive')), - value: kRemoteViewStyleAdaptive, - groupValue: groupValue, - onChanged: onChanged, - ffi: widget.ffi, - ), - Divider(), - ]); - }); + return futureBuilder( + future: toolbarViewStyle(context, widget.id, widget.ffi), + hasData: (data) { + final v = data as List>; + return Column(children: [ + ...v + .map((e) => _RadioMenuButton( + value: e.value, + groupValue: e.groupValue, + onChanged: e.onChanged, + child: e.child, + ffi: ffi)) + .toList(), + Divider(), + ]); + }); } scrollStyle() { - final visible = widget.state.viewStyle.value == kRemoteViewStyleOriginal; - if (!visible) return Offstage(); return futureBuilder(future: () async { + final viewStyle = await bind.sessionGetViewStyle(id: id) ?? ''; + final visible = viewStyle == kRemoteViewStyleOriginal; final scrollStyle = await bind.sessionGetScrollStyle(id: widget.id) ?? ''; - return scrollStyle; + return {'visible': visible, 'scrollStyle': scrollStyle}; }(), hasData: (data) { - final groupValue = data as String; + 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(id: widget.id, value: value); @@ -1104,269 +878,44 @@ class _DisplayMenuState extends State<_DisplayMenu> { } imageQuality() { - return futureBuilder(future: () async { - final imageQuality = - await bind.sessionGetImageQuality(id: widget.id) ?? ''; - return imageQuality; - }(), hasData: (data) { - final groupValue = data as String; - onChanged(String? value) async { - if (value == null) return; - await bind.sessionSetImageQuality(id: widget.id, value: value); - } - - return _SubmenuButton( - ffi: widget.ffi, - child: Text(translate('Image Quality')), - menuChildren: [ - _RadioMenuButton( - child: Text(translate('Good image quality')), - value: kRemoteImageQualityBest, - groupValue: groupValue, - onChanged: onChanged, + return futureBuilder( + future: toolbarImageQuality(context, widget.id, widget.ffi), + hasData: (data) { + final v = data as List>; + return _SubmenuButton( ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('Balanced')), - value: kRemoteImageQualityBalanced, - groupValue: groupValue, - onChanged: onChanged, - ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('Optimize reaction time')), - value: kRemoteImageQualityLow, - groupValue: groupValue, - onChanged: onChanged, - ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('Custom')), - value: kRemoteImageQualityCustom, - groupValue: groupValue, - onChanged: (value) { - onChanged(value); - _customImageQualityDialog(); - }, - ffi: widget.ffi, - ), - ], - ); - }); - } - - _customImageQualityDialog() async { - double qualityInitValue = 50; - double fpsInitValue = 30; - bool qualitySet = false; - bool fpsSet = false; - setCustomValues({double? quality, double? fps}) async { - if (quality != null) { - qualitySet = true; - await bind.sessionSetCustomImageQuality( - id: widget.id, value: quality.toInt()); - } - if (fps != null) { - fpsSet = true; - await bind.sessionSetCustomFps(id: widget.id, fps: fps.toInt()); - } - if (!qualitySet) { - qualitySet = true; - await bind.sessionSetCustomImageQuality( - id: widget.id, value: qualityInitValue.toInt()); - } - if (!fpsSet) { - fpsSet = true; - await bind.sessionSetCustomFps( - id: widget.id, fps: fpsInitValue.toInt()); - } - } - - final btnClose = dialogButton('Close', onPressed: () async { - await setCustomValues(); - widget.ffi.dialogManager.dismissAll(); - }); - - // quality - final quality = await bind.sessionGetCustomImageQuality(id: widget.id); - qualityInitValue = - quality != null && quality.isNotEmpty ? quality[0].toDouble() : 50.0; - const qualityMinValue = 10.0; - const qualityMaxValue = 100.0; - if (qualityInitValue < qualityMinValue) { - qualityInitValue = qualityMinValue; - } - if (qualityInitValue > qualityMaxValue) { - qualityInitValue = qualityMaxValue; - } - final RxDouble qualitySliderValue = RxDouble(qualityInitValue); - final debouncerQuality = Debouncer( - Duration(milliseconds: 1000), - onChanged: (double v) { - setCustomValues(quality: v); - }, - initialValue: qualityInitValue, - ); - final qualitySlider = Obx(() => Row( - children: [ - Slider( - value: qualitySliderValue.value, - min: qualityMinValue, - max: qualityMaxValue, - divisions: 18, - onChanged: (double value) { - qualitySliderValue.value = value; - debouncerQuality.value = value; - }, - ), - SizedBox( - width: 40, - child: Text( - '${qualitySliderValue.value.round()}%', - style: const TextStyle(fontSize: 15), - )), - SizedBox( - width: 50, - child: Text( - translate('Bitrate'), - style: const TextStyle(fontSize: 15), - )) - ], - )); - // fps - final fpsOption = - await bind.sessionGetOption(id: widget.id, arg: 'custom-fps'); - fpsInitValue = fpsOption == null ? 30 : double.tryParse(fpsOption) ?? 30; - if (fpsInitValue < 5 || fpsInitValue > 120) { - fpsInitValue = 30; - } - final RxDouble fpsSliderValue = RxDouble(fpsInitValue); - final debouncerFps = Debouncer( - Duration(milliseconds: 1000), - onChanged: (double v) { - setCustomValues(fps: v); - }, - initialValue: qualityInitValue, - ); - bool? direct; - try { - direct = ConnectionTypeState.find(widget.id).direct.value == - ConnectionType.strDirect; - } catch (_) {} - final fpsSlider = Offstage( - offstage: (await bind.mainIsUsingPublicServer() && direct != true) || - version_cmp(pi.version, '1.2.0') < 0, - child: Row( - children: [ - Obx((() => Slider( - value: fpsSliderValue.value, - min: 5, - max: 120, - divisions: 23, - onChanged: (double value) { - fpsSliderValue.value = value; - debouncerFps.value = value; - }, - ))), - SizedBox( - width: 40, - child: Obx(() => Text( - '${fpsSliderValue.value.round()}', - style: const TextStyle(fontSize: 15), - ))), - SizedBox( - width: 50, - child: Text( - translate('FPS'), - style: const TextStyle(fontSize: 15), - )) - ], - ), - ); - - final content = Column( - children: [qualitySlider, fpsSlider], - ); - msgBoxCommon( - widget.ffi.dialogManager, 'Custom Image Quality', content, [btnClose]); + child: Text(translate('Image Quality')), + menuChildren: v + .map((e) => _RadioMenuButton( + value: e.value, + groupValue: e.groupValue, + onChanged: e.onChanged, + child: e.child, + ffi: ffi)) + .toList(), + ); + }); } codec() { - return futureBuilder(future: () async { - final alternativeCodecs = - await bind.sessionAlternativeCodecs(id: widget.id); - final codecPreference = - await bind.sessionGetOption(id: widget.id, arg: 'codec-preference') ?? - ''; - return { - 'alternativeCodecs': alternativeCodecs, - 'codecPreference': codecPreference - }; - }(), hasData: (data) { - final List codecs = []; - try { - final Map codecsJson = jsonDecode(data['alternativeCodecs']); - final vp8 = codecsJson['vp8'] ?? false; - final h264 = codecsJson['h264'] ?? false; - final h265 = codecsJson['h265'] ?? false; - codecs.add(vp8); - codecs.add(h264); - codecs.add(h265); - } catch (e) { - debugPrint("Show Codec Preference err=$e"); - } - final visible = - codecs.length == 3 && (codecs[0] || codecs[1] || codecs[2]); - if (!visible) return Offstage(); - final groupValue = data['codecPreference'] as String; - onChanged(String? value) async { - if (value == null) return; - await bind.sessionPeerOption( - id: widget.id, name: 'codec-preference', value: value); - bind.sessionChangePreferCodec(id: widget.id); - } + 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: [ - _RadioMenuButton( - child: Text(translate('Auto')), - value: 'auto', - groupValue: groupValue, - onChanged: onChanged, + return _SubmenuButton( ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('VP8')), - value: 'vp8', - groupValue: groupValue, - onChanged: codecs[0] ? onChanged : null, - ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('VP9')), - value: 'vp9', - groupValue: groupValue, - onChanged: onChanged, - ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('H264')), - value: 'h264', - groupValue: groupValue, - onChanged: codecs[1] ? onChanged : null, - ffi: widget.ffi, - ), - _RadioMenuButton( - child: Text(translate('H265')), - value: 'h265', - groupValue: groupValue, - onChanged: codecs[2] ? onChanged : null, - ffi: widget.ffi, - ), - ]); - }); + child: Text(translate('Codec')), + menuChildren: v + .map((e) => _RadioMenuButton( + value: e.value, + groupValue: e.groupValue, + onChanged: e.onChanged, + child: e.child, + ffi: ffi)) + .toList()); + }); } resolutions() { @@ -1387,7 +936,7 @@ class _DisplayMenuState extends State<_DisplayMenu> { Future.delayed(Duration(seconds: 3), () async { final display = ffiModel.display; if (w == display.width && h == display.height) { - if (_isWindowCanBeAdjusted()) { + if (await _isWindowCanBeAdjusted()) { _doAdjustWindow(); } } @@ -1409,169 +958,21 @@ class _DisplayMenuState extends State<_DisplayMenu> { child: Text(translate("Resolution"))); } - showRemoteCursor() { - if (pi.platform == kPeerPlatformAndroid) { - return Offstage(); - } - final visible = - !widget.ffi.canvasModel.cursorEmbedded && !ffiModel.pi.is_wayland; - if (!visible) return Offstage(); - final enabled = !ffiModel.viewOnly; - final state = ShowRemoteCursorState.find(widget.id); - final option = 'show-remote-cursor'; - return _CheckboxMenuButton( - value: state.value, - onChanged: enabled - ? (value) async { - if (value == null) return; - await bind.sessionToggleOption(id: widget.id, value: option); - state.value = - bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - } - : null, - ffi: widget.ffi, - child: Text(translate('Show remote cursor'))); - } - - zoomCursor() { - if (pi.platform == kPeerPlatformAndroid) { - return Offstage(); - } - final visible = widget.state.viewStyle.value != kRemoteViewStyleOriginal; - if (!visible) return Offstage(); - final option = 'zoom-cursor'; - final peerState = PeerBoolOption.find(widget.id, option); - return _CheckboxMenuButton( - value: peerState.value, - onChanged: (value) async { - if (value == null) return; - await bind.sessionToggleOption(id: widget.id, value: option); - peerState.value = - bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - }, - ffi: widget.ffi, - child: Text(translate('Zoom cursor'))); - } - - showQualityMonitor() { - final option = 'show-quality-monitor'; - final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - return _CheckboxMenuButton( - value: value, - onChanged: (value) async { - if (value == null) return; - await bind.sessionToggleOption(id: widget.id, value: option); - widget.ffi.qualityMonitorModel.checkShowQualityMonitor(widget.id); - }, - ffi: widget.ffi, - child: Text(translate('Show quality monitor'))); - } - - mute() { - final visible = perms['audio'] != false; - if (!visible) return Offstage(); - final option = 'disable-audio'; - final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - return _CheckboxMenuButton( - value: value, - onChanged: (value) { - if (value == null) return; - bind.sessionToggleOption(id: widget.id, value: option); - }, - ffi: widget.ffi, - child: Text(translate('Mute'))); - } - - fileCopyAndPaste() { - final visible = Platform.isWindows && - pi.platform == kPeerPlatformWindows && - perms['file'] != false; - if (!visible) return Offstage(); - final option = 'enable-file-transfer'; - final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - return _CheckboxMenuButton( - value: value, - onChanged: (value) { - if (value == null) return; - bind.sessionToggleOption(id: widget.id, value: option); - }, - ffi: widget.ffi, - child: Text(translate('Allow file copy and paste'))); - } - - disableClipboard() { - final visible = ffiModel.keyboard && perms['clipboard'] != false; - if (!visible) return Offstage(); - final enabled = !ffiModel.viewOnly; - final option = 'disable-clipboard'; - var value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - if (ffiModel.viewOnly) value = true; - return _CheckboxMenuButton( - value: value, - onChanged: enabled - ? (value) { - if (value == null) return; - bind.sessionToggleOption(id: widget.id, value: option); - } - : null, - ffi: widget.ffi, - child: Text(translate('Disable clipboard'))); - } - - lockAfterSessionEnd() { - if (!ffiModel.keyboard) return Offstage(); - final option = 'lock-after-session-end'; - final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - return _CheckboxMenuButton( - value: value, - onChanged: (value) { - if (value == null) return; - bind.sessionToggleOption(id: widget.id, value: option); - }, - ffi: widget.ffi, - child: Text(translate('Lock after session end'))); - } - - privacyMode() { - bool visible = ffiModel.keyboard && pi.features.privacyMode; - if (!visible) return Offstage(); - final option = 'privacy-mode'; - final rxValue = PrivacyModeState.find(widget.id); - return _CheckboxMenuButton( - value: rxValue.value, - onChanged: (value) { - if (value == null) return; - if (ffiModel.pi.currentDisplay != 0) { - msgBox( - widget.id, - 'custom-nook-nocancel-hasclose', - 'info', - 'Please switch to Display 1 first', - '', - widget.ffi.dialogManager); - return; - } - bind.sessionToggleOption(id: widget.id, value: option); - }, - ffi: widget.ffi, - child: Text(translate('Privacy mode'))); - } - - swapKey() { - final visible = ffiModel.keyboard && - ((Platform.isMacOS && pi.platform != kPeerPlatformMacOS) || - (!Platform.isMacOS && pi.platform == kPeerPlatformMacOS)); - if (!visible) return Offstage(); - final option = 'allow_swap_key'; - final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option); - return _CheckboxMenuButton( - value: value, - onChanged: (value) { - if (value == null) return; - bind.sessionToggleOption(id: widget.id, value: option); - }, - ffi: widget.ffi, - child: Text(translate('Swap control-command key'))); + 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) => _CheckboxMenuButton( + value: e.value, + onChanged: e.onChanged, + child: e.child, + ffi: ffi)) + .toList()); + }); } } @@ -1799,19 +1200,22 @@ class _RecordMenu extends StatelessWidget { @override Widget build(BuildContext context) { var ffi = Provider.of(context); - final visible = ffi.permissions['recording'] != false; + var recordingModel = Provider.of(context); + final visible = + recordingModel.start || ffi.permissions['recording'] != false; if (!visible) return Offstage(); - return Consumer( - builder: (context, value, child) => _IconMenuButton( - assetName: 'assets/rec.svg', - tooltip: - value.start ? 'Stop session recording' : 'Start session recording', - onPressed: () => value.toggle(), - color: value.start ? _MenubarTheme.redColor : _MenubarTheme.blueColor, - hoverColor: value.start - ? _MenubarTheme.hoverRedColor - : _MenubarTheme.hoverBlueColor, - ), + return _IconMenuButton( + assetName: 'assets/rec.svg', + tooltip: recordingModel.start + ? 'Stop session recording' + : 'Start session recording', + onPressed: () => recordingModel.toggle(), + color: recordingModel.start + ? _MenubarTheme.redColor + : _MenubarTheme.blueColor, + hoverColor: recordingModel.start + ? _MenubarTheme.hoverRedColor + : _MenubarTheme.hoverBlueColor, ); } } diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 04852c98c..32c736dba 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -4,10 +4,13 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common/shared_state.dart'; +import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/mobile/widgets/gesture_help.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:get/get.dart'; import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; import 'package:provider/provider.dart'; import 'package:wakelock/wakelock.dart'; @@ -69,6 +72,7 @@ class _RemotePageState extends State { keyboardSubscription = keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged); _blockableOverlayState.applyFfi(gFFI); + initSharedStates(widget.id); } @override @@ -85,6 +89,7 @@ class _RemotePageState extends State { overlays: SystemUiOverlay.values); Wakelock.disable(); keyboardSubscription.cancel(); + removeSharedStates(widget.id); super.dispose(); } @@ -543,150 +548,21 @@ class _RemotePageState extends State { final size = MediaQuery.of(context).size; final x = 120.0; final y = size.height; - final more = >[]; - final pi = gFFI.ffiModel.pi; - final perms = gFFI.ffiModel.permissions; - if (pi.version.isNotEmpty) { - more.add(PopupMenuItem( - child: Text(translate('Refresh')), value: 'refresh')); - } - if (gFFI.ffiModel.pi.is_headless) { - more.add( - PopupMenuItem( - child: Row( - children: ([ - Text(translate('OS Account')), - TextButton( - style: flatButtonStyle, - onPressed: () { - showSetOSAccount(id, gFFI.dialogManager); - }, - child: Icon(Icons.edit, color: MyTheme.accent), - ) - ])), - value: 'enter_os_account'), - ); - } else { - more.add( - PopupMenuItem( - child: Row( - children: ([ - Text(translate('OS Password')), - TextButton( - style: flatButtonStyle, - onPressed: () { - showSetOSPassword(id, false, gFFI.dialogManager); - }, - child: Icon(Icons.edit, color: MyTheme.accent), - ) - ])), - value: 'enter_os_password'), - ); - } - if (!isWebDesktop) { - if (perms['keyboard'] != false && perms['clipboard'] != false) { - more.add(PopupMenuItem( - child: Text(translate('Paste')), value: 'paste')); - } - more.add(PopupMenuItem( - child: Text(translate('Reset canvas')), value: 'reset_canvas')); - } - if (perms['keyboard'] != false) { - // * Currently mobile does not enable map mode - // more.add(PopupMenuItem( - // child: Text(translate('Physical Keyboard Input Mode')), - // value: 'input-mode')); - if (pi.platform == kPeerPlatformLinux || pi.sasEnabled) { - more.add(PopupMenuItem( - child: Text('${translate('Insert')} Ctrl + Alt + Del'), - value: 'cad')); - } - more.add(PopupMenuItem( - child: Text(translate('Insert Lock')), value: 'lock')); - if (pi.platform == kPeerPlatformWindows && - await bind.sessionGetToggleOption(id: id, arg: 'privacy-mode') != - true) { - more.add(PopupMenuItem( - child: Text(translate( - '${gFFI.ffiModel.inputBlocked ? 'Unb' : 'B'}lock user input')), - value: 'block-input')); - } - } - if (perms["restart"] != false && - (pi.platform == kPeerPlatformLinux || - pi.platform == kPeerPlatformWindows || - pi.platform == kPeerPlatformMacOS)) { - more.add(PopupMenuItem( - child: Text(translate('Restart Remote Device')), value: 'restart')); - } - // Currently only support VP9 - if (gFFI.recordingModel.start || - (perms["recording"] != false && - gFFI.qualityMonitorModel.data.codecFormat == "VP9")) { - more.add(PopupMenuItem( - child: Row( - children: [ - Text(translate(gFFI.recordingModel.start - ? 'Stop session recording' - : 'Start session recording')), - Padding( - padding: EdgeInsets.only(left: 12), - child: Icon( - gFFI.recordingModel.start - ? Icons.pause_circle_filled - : Icons.videocam_outlined, - color: MyTheme.accent), - ) - ], - ), - value: 'record')); - } + final menus = toolbarControls(context, id, gFFI); + final more = menus + .asMap() + .entries + .map((e) => PopupMenuItem(child: e.value.child, value: e.key)) + .toList(); () async { - var value = await showMenu( + var index = await showMenu( context: context, position: RelativeRect.fromLTRB(x, y, x, y), items: more, elevation: 8, ); - if (value == 'cad') { - bind.sessionCtrlAltDel(id: widget.id); - // * Currently mobile does not enable map mode - // } else if (value == 'input-mode') { - // changePhysicalKeyboardInputMode(); - } else if (value == 'lock') { - bind.sessionLockScreen(id: widget.id); - } else if (value == 'block-input') { - bind.sessionToggleOption( - id: widget.id, - value: '${gFFI.ffiModel.inputBlocked ? 'un' : ''}block-input'); - gFFI.ffiModel.inputBlocked = !gFFI.ffiModel.inputBlocked; - } else if (value == 'refresh') { - bind.sessionRefresh(id: widget.id); - } else if (value == 'paste') { - () async { - ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); - if (data != null && data.text != null) { - bind.sessionInputString(id: widget.id, value: data.text ?? ""); - } - }(); - } else if (value == 'enter_os_password') { - // FIXME: - // null means no session of id - // empty string means no password - var password = await bind.sessionGetOption(id: id, arg: 'os-password'); - if (password != null) { - bind.sessionInputOsPassword(id: widget.id, value: password); - } else { - showSetOSPassword(id, true, gFFI.dialogManager); - } - } else if (value == 'enter_os_account') { - showSetOSAccount(id, gFFI.dialogManager); - } else if (value == 'reset_canvas') { - gFFI.cursorModel.reset(); - } else if (value == 'restart') { - showRestartRemoteDevice(pi, widget.id, gFFI.dialogManager); - } else if (value == 'record') { - gFFI.recordingModel.toggle(); + if (index != null && index < menus.length) { + menus[index].onPressed.call(); } }(); } @@ -941,14 +817,6 @@ class CursorPaint extends StatelessWidget { void showOptions( BuildContext context, String id, OverlayDialogManager dialogManager) async { - String quality = - await bind.sessionGetImageQuality(id: id) ?? kRemoteImageQualityBalanced; - if (quality == '') quality = kRemoteImageQualityBalanced; - String codec = - await bind.sessionGetOption(id: id, arg: 'codec-preference') ?? 'auto'; - if (codec == '') codec = 'auto'; - String viewStyle = await bind.sessionGetViewStyle(id: id) ?? ''; - var displays = []; final pi = gFFI.ffiModel.pi; final image = gFFI.ffiModel.getConnectionImage(); @@ -991,107 +859,61 @@ void showOptions( if (displays.isNotEmpty) { displays.add(const Divider(color: MyTheme.border)); } - final perms = gFFI.ffiModel.permissions; - final hasHwcodec = bind.mainHasHwcodec(); - final List codecs = []; - try { - final Map codecsJson = - jsonDecode(await bind.sessionAlternativeCodecs(id: id)); - final vp8 = codecsJson['vp8'] ?? false; - final h264 = codecsJson['h264'] ?? false; - final h265 = codecsJson['h265'] ?? false; - codecs.add(vp8); - codecs.add(h264); - codecs.add(h265); - } catch (e) { - debugPrint("Show Codec Preference err=$e"); - } + + List> viewStyleRadios = + await toolbarViewStyle(context, id, gFFI); + List> imageQualityRadios = + await toolbarImageQuality(context, id, gFFI); + List> codecRadios = await toolbarCodec(context, id, gFFI); + List displayToggles = + await toolbarDisplayToggle(context, id, gFFI); dialogManager.show((setState, close) { - final more = []; - if (perms['audio'] != false) { - more.add(getToggle(id, setState, 'disable-audio', 'Mute')); - } - if (perms['keyboard'] != false) { - if (perms['clipboard'] != false) { - more.add( - getToggle(id, setState, 'disable-clipboard', 'Disable clipboard')); - } - more.add(getToggle( - id, setState, 'lock-after-session-end', 'Lock after session end')); - if (pi.platform == kPeerPlatformWindows) { - more.add(getToggle(id, setState, 'privacy-mode', 'Privacy mode')); - } - } - setQuality(String? value) { - if (value == null) return; - setState(() { - quality = value; - bind.sessionSetImageQuality(id: id, value: value); - }); - } - - setViewStyle(String? value) { - if (value == null) return; - setState(() { - viewStyle = value; - bind - .sessionSetViewStyle(id: id, value: value) - .then((_) => gFFI.canvasModel.updateViewStyle()); - }); - } - - setCodec(String? value) { - if (value == null) return; - setState(() { - codec = value; - bind - .sessionPeerOption(id: id, name: "codec-preference", value: value) - .then((_) => bind.sessionChangePreferCodec(id: id)); - }); - } - + var viewStyle = + (viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs; + var imageQuality = + (imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '') + .obs; + var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs; final radios = [ - getRadio( - 'Scale original', kRemoteViewStyleOriginal, viewStyle, setViewStyle), - getRadio( - 'Scale adaptive', kRemoteViewStyleAdaptive, viewStyle, setViewStyle), + for (var e in viewStyleRadios) + Obx(() => getRadio(e.child, e.value, viewStyle.value, (v) { + e.onChanged?.call(v); + if (v != null) viewStyle.value = v; + })), const Divider(color: MyTheme.border), - getRadio( - 'Good image quality', kRemoteImageQualityBest, quality, setQuality), - getRadio('Balanced', kRemoteImageQualityBalanced, quality, setQuality), - getRadio('Optimize reaction time', kRemoteImageQualityLow, quality, - setQuality), - const Divider(color: MyTheme.border) + for (var e in imageQualityRadios) + Obx(() => getRadio(e.child, e.value, imageQuality.value, (v) { + e.onChanged?.call(v); + if (v != null) imageQuality.value = v; + })), + const Divider(color: MyTheme.border), + for (var e in codecRadios) + Obx(() => getRadio(e.child, e.value, codec.value, (v) { + e.onChanged?.call(v); + if (v != null) codec.value = v; + })), + if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border), ]; - - if (codecs.length == 3 && (codecs[0] || codecs[1] || codecs[2])) { - radios.add(getRadio(translate('Auto'), 'auto', codec, setCodec)); - if (codecs[0]) { - radios.add(getRadio('VP8', 'vp8', codec, setCodec)); - } - radios.add(getRadio('VP9', 'vp9', codec, setCodec)); - if (codecs[1]) { - radios.add(getRadio('H264', 'h264', codec, setCodec)); - } - if (codecs[2]) { - radios.add(getRadio('H265', 'h265', codec, setCodec)); - } - radios.add(const Divider(color: MyTheme.border)); - } - - final toggles = [ - getToggle(id, setState, 'show-quality-monitor', 'Show quality monitor'), - ]; - if (!gFFI.canvasModel.cursorEmbedded && !pi.is_wayland) { - toggles.insert(0, - getToggle(id, setState, 'show-remote-cursor', 'Show remote cursor')); - } + final rxToggleValues = displayToggles.map((e) => e.value.obs).toList(); + final toggles = displayToggles + .asMap() + .entries + .map((e) => Obx(() => CheckboxListTile( + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + value: rxToggleValues[e.key].value, + onChanged: (v) { + e.value.onChanged?.call(v); + if (v != null) rxToggleValues[e.key].value = v; + }, + title: e.value.child))) + .toList(); return CustomAlertDialog( content: Column( mainAxisSize: MainAxisSize.min, - children: displays + radios + toggles + more), + children: displays + radios + toggles), ); }, clickMaskDismiss: true, backDismiss: true); } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index 96038f983..ff7c9e240 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -504,13 +504,13 @@ void showLanguageSettings(OverlayDialogManager dialogManager) async { return CustomAlertDialog( content: Column( children: [ - getRadio('Default', '', lang, setLang), + getRadio(Text(translate('Default')), '', lang, setLang), Divider(color: MyTheme.border), ] + langs.map((e) { final key = e[0] as String; final name = e[1] as String; - return getRadio(name, key, lang, setLang); + return getRadio(Text(translate(name)), key, lang, setLang); }).toList(), ), ); @@ -536,9 +536,11 @@ void showThemeSettings(OverlayDialogManager dialogManager) async { return CustomAlertDialog( content: Column(children: [ - getRadio('Light', ThemeMode.light, themeMode, setTheme), - getRadio('Dark', ThemeMode.dark, themeMode, setTheme), - getRadio('Follow System', ThemeMode.system, themeMode, setTheme) + getRadio( + Text(translate('Light')), ThemeMode.light, themeMode, setTheme), + getRadio(Text(translate('Dark')), ThemeMode.dark, themeMode, setTheme), + getRadio(Text(translate('Follow System')), ThemeMode.system, themeMode, + setTheme) ]), ); }, backDismiss: true, clickMaskDismiss: true);