merge mobile/desktop remote toobar code

Signed-off-by: 21pages <pages21@163.com>
This commit is contained in:
21pages 2023-04-12 09:41:13 +08:00
parent af01581abb
commit b2c0590898
8 changed files with 882 additions and 999 deletions

View file

@ -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, its not used, getToggle() has not
RadioListTile<T> getRadio<T>(
String name, T toValue, T curValue, void Function(T?) onChange,
Widget title, T toValue, T curValue, ValueChanged<T?>? onChange,
{EdgeInsetsGeometry? contentPadding}) {
return RadioListTile<T>(
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) {

View file

@ -261,3 +261,23 @@ class PeerStringOption {
static RxString find(String id, String opt) =>
Get.find<RxString>(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');
}

View file

@ -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<bool>((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<double>(
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<double>(
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]);
}

View file

@ -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<T> {
final Widget child;
final T value;
final T groupValue;
final ValueChanged<T?>? onChanged;
TRadioMenu(
{required this.child,
required this.value,
required this.groupValue,
required this.onChanged});
}
class TToggleMenu {
final Widget child;
final bool value;
final ValueChanged<bool?>? onChanged;
TToggleMenu(
{required this.child, required this.value, required this.onChanged});
}
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final perms = ffiModel.permissions;
List<TTextMenu> 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<List<TRadioMenu<String>>> 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<String>(
child: Text(translate('Scale original')),
value: kRemoteViewStyleOriginal,
groupValue: groupValue,
onChanged: onChanged),
TRadioMenu<String>(
child: Text(translate('Scale adaptive')),
value: kRemoteViewStyleAdaptive,
groupValue: groupValue,
onChanged: onChanged)
];
}
Future<List<TRadioMenu<String>>> 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<String>(
child: Text(translate('Good image quality')),
value: kRemoteImageQualityBest,
groupValue: groupValue,
onChanged: onChanged),
TRadioMenu<String>(
child: Text(translate('Balanced')),
value: kRemoteImageQualityBalanced,
groupValue: groupValue,
onChanged: onChanged),
TRadioMenu<String>(
child: Text(translate('Optimize reaction time')),
value: kRemoteImageQualityLow,
groupValue: groupValue,
onChanged: onChanged),
TRadioMenu<String>(
child: Text(translate('Custom')),
value: kRemoteImageQualityCustom,
groupValue: groupValue,
onChanged: (value) {
onChanged(value);
customImageQualityDialog(id, ffi);
},
),
];
}
Future<List<TRadioMenu<String>>> 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<bool> 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<String> radio(String label, String value, bool enabled) {
return TRadioMenu<String>(
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<List<TToggleMenu>> toolbarDisplayToggle(
BuildContext context, String id, FFI ffi) async {
List<TToggleMenu> 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;
}

View file

@ -77,15 +77,8 @@ class _RemotePageState extends State<RemotePage>
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<RemotePage>
_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<RemotePage>
}
Get.delete<FFI>(tag: widget.id);
super.dispose();
_removeStates(widget.id);
removeSharedStates(widget.id);
}
Widget buildBody(BuildContext context) {

View file

@ -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<RemoteMenubar> {
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<bool> _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<String>(
child: Text(translate('Scale original')),
value: kRemoteViewStyleOriginal,
groupValue: groupValue,
onChanged: onChanged,
ffi: widget.ffi,
),
_RadioMenuButton<String>(
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<TRadioMenu<String>>;
return Column(children: [
...v
.map((e) => _RadioMenuButton<String>(
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<String>(
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<TRadioMenu<String>>;
return _SubmenuButton(
ffi: widget.ffi,
),
_RadioMenuButton<String>(
child: Text(translate('Balanced')),
value: kRemoteImageQualityBalanced,
groupValue: groupValue,
onChanged: onChanged,
ffi: widget.ffi,
),
_RadioMenuButton<String>(
child: Text(translate('Optimize reaction time')),
value: kRemoteImageQualityLow,
groupValue: groupValue,
onChanged: onChanged,
ffi: widget.ffi,
),
_RadioMenuButton<String>(
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<double>(
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<double>(
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<String>(
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<bool> 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<TRadioMenu<String>>;
if (v.isEmpty) return Offstage();
return _SubmenuButton(
ffi: widget.ffi,
child: Text(translate('Codec')),
menuChildren: [
_RadioMenuButton<String>(
child: Text(translate('Auto')),
value: 'auto',
groupValue: groupValue,
onChanged: onChanged,
return _SubmenuButton(
ffi: widget.ffi,
),
_RadioMenuButton<String>(
child: Text(translate('VP8')),
value: 'vp8',
groupValue: groupValue,
onChanged: codecs[0] ? onChanged : null,
ffi: widget.ffi,
),
_RadioMenuButton<String>(
child: Text(translate('VP9')),
value: 'vp9',
groupValue: groupValue,
onChanged: onChanged,
ffi: widget.ffi,
),
_RadioMenuButton<String>(
child: Text(translate('H264')),
value: 'h264',
groupValue: groupValue,
onChanged: codecs[1] ? onChanged : null,
ffi: widget.ffi,
),
_RadioMenuButton<String>(
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<TToggleMenu>;
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<FfiModel>(context);
final visible = ffi.permissions['recording'] != false;
var recordingModel = Provider.of<RecordingModel>(context);
final visible =
recordingModel.start || ffi.permissions['recording'] != false;
if (!visible) return Offstage();
return Consumer<RecordingModel>(
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,
);
}
}

View file

@ -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<RemotePage> {
keyboardSubscription =
keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged);
_blockableOverlayState.applyFfi(gFFI);
initSharedStates(widget.id);
}
@override
@ -85,6 +89,7 @@ class _RemotePageState extends State<RemotePage> {
overlays: SystemUiOverlay.values);
Wakelock.disable();
keyboardSubscription.cancel();
removeSharedStates(widget.id);
super.dispose();
}
@ -543,150 +548,21 @@ class _RemotePageState extends State<RemotePage> {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
final more = <PopupMenuItem<String>>[];
final pi = gFFI.ffiModel.pi;
final perms = gFFI.ffiModel.permissions;
if (pi.version.isNotEmpty) {
more.add(PopupMenuItem<String>(
child: Text(translate('Refresh')), value: 'refresh'));
}
if (gFFI.ffiModel.pi.is_headless) {
more.add(
PopupMenuItem<String>(
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<String>(
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<String>(
child: Text(translate('Paste')), value: 'paste'));
}
more.add(PopupMenuItem<String>(
child: Text(translate('Reset canvas')), value: 'reset_canvas'));
}
if (perms['keyboard'] != false) {
// * Currently mobile does not enable map mode
// more.add(PopupMenuItem<String>(
// child: Text(translate('Physical Keyboard Input Mode')),
// value: 'input-mode'));
if (pi.platform == kPeerPlatformLinux || pi.sasEnabled) {
more.add(PopupMenuItem<String>(
child: Text('${translate('Insert')} Ctrl + Alt + Del'),
value: 'cad'));
}
more.add(PopupMenuItem<String>(
child: Text(translate('Insert Lock')), value: 'lock'));
if (pi.platform == kPeerPlatformWindows &&
await bind.sessionGetToggleOption(id: id, arg: 'privacy-mode') !=
true) {
more.add(PopupMenuItem<String>(
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<String>(
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<String>(
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<int>(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 = <Widget>[];
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<bool> 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<TRadioMenu<String>> viewStyleRadios =
await toolbarViewStyle(context, id, gFFI);
List<TRadioMenu<String>> imageQualityRadios =
await toolbarImageQuality(context, id, gFFI);
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI);
dialogManager.show((setState, close) {
final more = <Widget>[];
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<String>(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<String>(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<String>(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);
}

View file

@ -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);