21pages 09466680d3
mobile virtual display, resolution menu, proxy setting ()
1. Merge code of mobile and desktop virtual display menu.
2. Mobile add seperate resolution menu, only support changing
   resolutions.
3. Android add proxy setting

Signed-off-by: 21pages <sunboeasy@gmail.com>
2024-07-15 18:53:14 +08:00

2411 lines
71 KiB
Dart

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