mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-04-17 21:01:28 +03:00
view camera (#11040)
* view camera Signed-off-by: 21pages <sunboeasy@gmail.com> * `No cameras` prompt if no cameras available, `peerGetSessionsCount` use connType as parameter Signed-off-by: 21pages <sunboeasy@gmail.com> * fix, use video_service_name rather than display_idx as key in qos,etc Signed-off-by: 21pages <sunboeasy@gmail.com> --------- Signed-off-by: 21pages <sunboeasy@gmail.com> Co-authored-by: Adwin White <adwinw01@gmail.com> Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
This commit is contained in:
@@ -17,7 +17,6 @@ import '../../common/formatter/id_formatter.dart';
|
||||
import '../../common/widgets/peer_tab_page.dart';
|
||||
import '../../common/widgets/autocomplete.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../widgets/button.dart';
|
||||
|
||||
class OnlineStatusWidget extends StatefulWidget {
|
||||
const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
|
||||
@@ -203,6 +202,8 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
final FocusNode _idFocusNode = FocusNode();
|
||||
final TextEditingController _idEditingController = TextEditingController();
|
||||
|
||||
String selectedConnectionType = 'Connect';
|
||||
|
||||
bool isWindowMinimized = false;
|
||||
|
||||
final AllPeersLoader _allPeersLoader = AllPeersLoader();
|
||||
@@ -321,9 +322,10 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
|
||||
/// Callback for the connect button.
|
||||
/// Connects to the selected peer.
|
||||
void onConnect({bool isFileTransfer = false}) {
|
||||
void onConnect({bool isFileTransfer = false, bool isViewCamera = false}) {
|
||||
var id = _idController.id;
|
||||
connect(context, id, isFileTransfer: isFileTransfer);
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer, isViewCamera: isViewCamera);
|
||||
}
|
||||
|
||||
/// UI for the remote ID TextField.
|
||||
@@ -501,21 +503,64 @@ class _ConnectionPageState extends State<ConnectionPage>
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 13.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Button(
|
||||
isOutline: true,
|
||||
onTap: () => onConnect(isFileTransfer: true),
|
||||
text: "Transfer file",
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
SizedBox(
|
||||
height: 28.0,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
onConnect();
|
||||
},
|
||||
child: Text(translate("Connect")),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 17,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Container(
|
||||
height: 28.0,
|
||||
width: 28.0,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
Button(onTap: onConnect, text: "Connect"),
|
||||
],
|
||||
),
|
||||
)
|
||||
child: Center(
|
||||
child: MenuAnchor(
|
||||
builder: (context, controller, builder) {
|
||||
return IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: controller.isOpen
|
||||
? const Icon(Icons.keyboard_arrow_up)
|
||||
: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
menuChildren: <Widget>[
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onConnect(isFileTransfer: true);
|
||||
},
|
||||
child: Text(translate('Transfer file')),
|
||||
),
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onConnect(isViewCamera: true);
|
||||
},
|
||||
child: Text(translate('View camera')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -775,6 +775,7 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
await connectMainDesktop(
|
||||
call.arguments['id'],
|
||||
isFileTransfer: call.arguments['isFileTransfer'],
|
||||
isViewCamera: call.arguments['isViewCamera'],
|
||||
isTcpTunneling: call.arguments['isTcpTunneling'],
|
||||
isRDP: call.arguments['isRDP'],
|
||||
password: call.arguments['password'],
|
||||
@@ -789,9 +790,15 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
} catch (e) {
|
||||
debugPrint("Failed to parse window id '${call.arguments}': $e");
|
||||
}
|
||||
if (windowId != null) {
|
||||
WindowType? windowType;
|
||||
try {
|
||||
windowType = WindowType.values.byName(args[3]);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to parse window type '${call.arguments}': $e");
|
||||
}
|
||||
if (windowId != null && windowType != null) {
|
||||
await rustDeskWinManager.moveTabToNewWindow(
|
||||
windowId, args[1], args[2]);
|
||||
windowId, args[1], args[2], windowType);
|
||||
}
|
||||
} else if (call.method == kWindowEventOpenMonitorSession) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
@@ -799,9 +806,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
|
||||
final peerId = args['peer_id'] as String;
|
||||
final display = args['display'] as int;
|
||||
final displayCount = args['display_count'] as int;
|
||||
final windowType = args['window_type'] as int;
|
||||
final screenRect = parseParamScreenRect(args);
|
||||
await rustDeskWinManager.openMonitorSession(
|
||||
windowId, peerId, display, displayCount, screenRect);
|
||||
windowId, peerId, display, displayCount, screenRect, windowType);
|
||||
} else if (call.method == kWindowEventRemoteWindowCoords) {
|
||||
final windowId = int.tryParse(call.arguments);
|
||||
if (windowId != null) {
|
||||
|
||||
@@ -960,6 +960,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable audio', kOptionEnableAudio,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable camera', kOptionEnableCamera,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(
|
||||
context, 'Enable TCP tunneling', kOptionEnableTunnel,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
|
||||
@@ -269,8 +269,10 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
style: style,
|
||||
),
|
||||
proc: () async {
|
||||
await DesktopMultiWindow.invokeMethod(kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow, '${windowId()},$key,$sessionId');
|
||||
await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow,
|
||||
'${windowId()},$key,$sessionId,RemoteDesktop');
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
@@ -417,8 +419,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
|
||||
await WindowController.fromWindowId(windowId()).setFullscreen(false);
|
||||
stateGlobal.setFullscreen(false, procWnd: false);
|
||||
}
|
||||
await setNewConnectWindowFrame(
|
||||
windowId(), id!, prePeerCount, display, screenRect);
|
||||
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
|
||||
WindowType.RemoteDesktop, display, screenRect);
|
||||
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
|
||||
await windowOnTop(windowId());
|
||||
});
|
||||
|
||||
@@ -353,7 +353,9 @@ Widget buildConnectionCard(Client client) {
|
||||
key: ValueKey(client.id),
|
||||
children: [
|
||||
_CmHeader(client: client),
|
||||
client.type_() != ClientType.remote || client.disconnected
|
||||
client.type_() == ClientType.file ||
|
||||
client.type_() == ClientType.portForward ||
|
||||
client.disconnected
|
||||
? Offstage()
|
||||
: _PrivilegeBoard(client: client),
|
||||
Expanded(
|
||||
@@ -526,7 +528,8 @@ class _CmHeaderState extends State<_CmHeader>
|
||||
Offstage(
|
||||
offstage: !client.authorized ||
|
||||
(client.type_() != ClientType.remote &&
|
||||
client.type_() != ClientType.file),
|
||||
client.type_() != ClientType.file &&
|
||||
client.type_() != ClientType.camera),
|
||||
child: IconButton(
|
||||
onPressed: () => checkClickTime(client.id, () {
|
||||
if (client.type_() == ClientType.file) {
|
||||
@@ -627,96 +630,139 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
padding: EdgeInsets.symmetric(horizontal: spacing),
|
||||
mainAxisSpacing: spacing,
|
||||
crossAxisSpacing: spacing,
|
||||
children: [
|
||||
buildPermissionIcon(
|
||||
client.keyboard,
|
||||
Icons.keyboard,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "keyboard", enabled: enabled);
|
||||
setState(() {
|
||||
client.keyboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
Icons.assignment_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "clipboard", enabled: enabled);
|
||||
setState(() {
|
||||
client.clipboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "audio", enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
Icons.upload_file_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "file", enabled: enabled);
|
||||
setState(() {
|
||||
client.file = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
Icons.restart_alt_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "restart", enabled: enabled);
|
||||
setState(() {
|
||||
client.restart = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id, name: "recording", enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
buildPermissionIcon(
|
||||
client.blockInput,
|
||||
Icons.block,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "block_input",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.blockInput = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
)
|
||||
],
|
||||
children: client.type_() == ClientType.camera
|
||||
? [
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "audio",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "recording",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
]
|
||||
: [
|
||||
buildPermissionIcon(
|
||||
client.keyboard,
|
||||
Icons.keyboard,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "keyboard",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.keyboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
Icons.assignment_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "clipboard",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.clipboard = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
Icons.volume_up_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "audio",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.audio = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
Icons.upload_file_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "file",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.file = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
Icons.restart_alt_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "restart",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.restart = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
Icons.videocam_rounded,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "recording",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.recording = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
buildPermissionIcon(
|
||||
client.blockInput,
|
||||
Icons.block,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "block_input",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.blockInput = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
730
flutter/lib/desktop/pages/view_camera_page.dart
Normal file
730
flutter/lib/desktop/pages/view_camera_page.dart
Normal file
@@ -0,0 +1,730 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import '../../consts.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/toolbar.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../common/shared_state.dart';
|
||||
import '../../utils/image.dart';
|
||||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
|
||||
final SimpleWrapper<bool> _firstEnterImage = SimpleWrapper(false);
|
||||
|
||||
// Used to skip session close if "move to new window" is clicked.
|
||||
final Map<String, bool> closeSessionOnDispose = {};
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.toolbarState,
|
||||
this.sessionId,
|
||||
this.tabWindowId,
|
||||
this.password,
|
||||
this.display,
|
||||
this.displays,
|
||||
this.tabController,
|
||||
this.connToken,
|
||||
this.forceRelay,
|
||||
this.isSharedPassword,
|
||||
}) : super(key: key) {
|
||||
initSharedStates(id);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final SessionID? sessionId;
|
||||
final int? tabWindowId;
|
||||
final int? display;
|
||||
final List<int>? displays;
|
||||
final String? password;
|
||||
final ToolbarState toolbarState;
|
||||
final bool? forceRelay;
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final SimpleWrapper<State<ViewCameraPage>?> _lastState = SimpleWrapper(null);
|
||||
final DesktopTabController? tabController;
|
||||
|
||||
FFI get ffi => (_lastState.value! as _ViewCameraPageState)._ffi;
|
||||
|
||||
@override
|
||||
State<ViewCameraPage> createState() {
|
||||
final state = _ViewCameraPageState(id);
|
||||
_lastState.value = state;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
with AutomaticKeepAliveClientMixin, MultiWindowListener {
|
||||
Timer? _timer;
|
||||
String keyboardMode = "legacy";
|
||||
bool _isWindowBlur = false;
|
||||
final _cursorOverImage = false.obs;
|
||||
|
||||
var _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
||||
|
||||
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
||||
// to identify the toolbar instance and its callback function.
|
||||
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
||||
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
||||
|
||||
late FFI _ffi;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
_ViewCameraPageState(String id) {
|
||||
_initStates(id);
|
||||
}
|
||||
|
||||
void _initStates(String id) {}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ffi = FFI(widget.sessionId);
|
||||
Get.put<FFI>(_ffi, tag: widget.id);
|
||||
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
showKBLayoutTypeChooserIfNeeded(
|
||||
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
||||
_ffi.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
||||
});
|
||||
_ffi.start(
|
||||
widget.id,
|
||||
isViewCamera: true,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
forceRelay: widget.forceRelay,
|
||||
tabWindowId: widget.tabWindowId,
|
||||
display: widget.display,
|
||||
displays: widget.displays,
|
||||
connToken: widget.connToken,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
_ffi.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
|
||||
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
|
||||
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
||||
DesktopMultiWindow.addListener(this);
|
||||
// if (!_isCustomCursorInited) {
|
||||
// customCursorController.registerNeedUpdateCursorCallback(
|
||||
// (String? lastKey, String? currentKey) async {
|
||||
// if (_firstEnterImage.value) {
|
||||
// _firstEnterImage.value = false;
|
||||
// return true;
|
||||
// }
|
||||
// return lastKey == null || lastKey != currentKey;
|
||||
// });
|
||||
// _isCustomCursorInited = true;
|
||||
// }
|
||||
|
||||
_blockableOverlayState.applyFfi(_ffi);
|
||||
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.tabController?.onSelected?.call(widget.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowBlur() {
|
||||
super.onWindowBlur();
|
||||
// On windows, we use `focus` way to handle keyboard better.
|
||||
// Now on Linux, there's some rdev issues which will break the input.
|
||||
// We disable the `focus` way for non-Windows temporarily.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = true;
|
||||
// unfocus the primary-focus when the whole window is lost focus,
|
||||
// and let OS to handle events instead.
|
||||
_rawKeyFocusNode.unfocus();
|
||||
}
|
||||
stateGlobal.isFocused.value = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowFocus() {
|
||||
super.onWindowFocus();
|
||||
// See [onWindowBlur].
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
stateGlobal.isFocused.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowRestore() {
|
||||
super.onWindowRestore();
|
||||
// On windows, we use `onWindowRestore` way to handle window restore from
|
||||
// a minimized state.
|
||||
if (isWindows) {
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
super.onWindowMaximize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMinimize() {
|
||||
super.onWindowMinimize();
|
||||
if (!isLinux) {
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowEnterFullScreen() {
|
||||
super.onWindowEnterFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowLeaveFullScreen() {
|
||||
super.onWindowLeaveFullScreen();
|
||||
if (isMacOS) {
|
||||
stateGlobal.setFullscreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
||||
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
||||
_ffi.textureModel.onViewCameraPageDispose(closeSession);
|
||||
if (closeSession) {
|
||||
// ensure we leave this session, this is a double check
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
DesktopMultiWindow.removeListener(this);
|
||||
_ffi.dialogManager.hideMobileActionsOverlay();
|
||||
_ffi.imageModel.disposeImage();
|
||||
_ffi.cursorModel.disposeImages();
|
||||
_rawKeyFocusNode.dispose();
|
||||
await _ffi.close(closeSession: closeSession);
|
||||
_timer?.cancel();
|
||||
_ffi.dialogManager.dismissAll();
|
||||
if (closeSession) {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
}
|
||||
if (!isLinux) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
await Get.delete<FFI>(tag: widget.id);
|
||||
removeSharedStates(widget.id);
|
||||
}
|
||||
|
||||
Widget emptyOverlay() => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
state: _blockableOverlayState,
|
||||
underlying: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildBody(BuildContext context) {
|
||||
remoteToolbar(BuildContext context) => RemoteToolbar(
|
||||
id: widget.id,
|
||||
ffi: _ffi,
|
||||
state: widget.toolbarState,
|
||||
onEnterOrLeaveImageSetter: (id, func) {
|
||||
_instanceIdOnEnterOrLeaveImage4Toolbar = id;
|
||||
_onEnterOrLeaveImage4Toolbar = func;
|
||||
},
|
||||
onEnterOrLeaveImageCleaner: (id) {
|
||||
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
|
||||
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
|
||||
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
|
||||
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
|
||||
_onEnterOrLeaveImage4Toolbar = null;
|
||||
}
|
||||
},
|
||||
setRemoteState: setState,
|
||||
);
|
||||
|
||||
bodyWidget() {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
color: kColorCanvas,
|
||||
child: getBodyForDesktop(context),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
_ffi.ffiModel.pi.isSet.isTrue &&
|
||||
_ffi.ffiModel.waitForFirstImage.isTrue
|
||||
? emptyOverlay()
|
||||
: () {
|
||||
if (!_ffi.ffiModel.isPeerAndroid) {
|
||||
return Offstage();
|
||||
} else {
|
||||
return Obx(() => Offstage(
|
||||
offstage: _ffi.dialogManager
|
||||
.mobileActionsOverlayVisible.isFalse,
|
||||
child: Overlay(initialEntries: [
|
||||
makeMobileActionsOverlayEntry(
|
||||
() => _ffi.dialogManager
|
||||
.setMobileActionsOverlayVisible(false),
|
||||
ffi: _ffi,
|
||||
)
|
||||
]),
|
||||
));
|
||||
}
|
||||
}(),
|
||||
// Use Overlay to enable rebuild every time on menu button click.
|
||||
_ffi.ffiModel.pi.isSet.isTrue
|
||||
? Overlay(
|
||||
initialEntries: [OverlayEntry(builder: remoteToolbar)])
|
||||
: remoteToolbar(context),
|
||||
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: Obx(() {
|
||||
final imageReady = _ffi.ffiModel.pi.isSet.isTrue &&
|
||||
_ffi.ffiModel.waitForFirstImage.isFalse;
|
||||
if (imageReady) {
|
||||
// If the privacy mode(disable physical displays) is switched,
|
||||
// we should not dismiss the dialog immediately.
|
||||
if (DateTime.now().difference(togglePrivacyModeTime) >
|
||||
const Duration(milliseconds: 3000)) {
|
||||
// `dismissAll()` is to ensure that the state is clean.
|
||||
// It's ok to call dismissAll() here.
|
||||
_ffi.dialogManager.dismissAll();
|
||||
// Recreate the block state to refresh the state.
|
||||
_blockableOverlayState = BlockableOverlayState();
|
||||
_blockableOverlayState.applyFfi(_ffi);
|
||||
}
|
||||
// Block the whole `bodyWidget()` when dialog shows.
|
||||
return BlockableOverlay(
|
||||
underlying: bodyWidget(),
|
||||
state: _blockableOverlayState,
|
||||
);
|
||||
} else {
|
||||
// `_blockableOverlayState` is not recreated here.
|
||||
// The toolbar's block state won't work properly when reconnecting, but that's okay.
|
||||
return bodyWidget();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, _ffi.dialogManager);
|
||||
return false;
|
||||
},
|
||||
child: MultiProvider(providers: [
|
||||
ChangeNotifierProvider.value(value: _ffi.ffiModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.imageModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.cursorModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.canvasModel),
|
||||
ChangeNotifierProvider.value(value: _ffi.recordingModel),
|
||||
], child: buildBody(context)));
|
||||
}
|
||||
|
||||
void enterView(PointerEnterEvent evt) {
|
||||
_cursorOverImage.value = true;
|
||||
_firstEnterImage.value = true;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
try {
|
||||
_onEnterOrLeaveImage4Toolbar!(true);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
_ffi.inputModel.enterOrLeave(true);
|
||||
}
|
||||
}
|
||||
|
||||
void leaveView(PointerExitEvent evt) {
|
||||
if (_ffi.ffiModel.keyboard) {
|
||||
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
||||
}
|
||||
|
||||
_cursorOverImage.value = false;
|
||||
_firstEnterImage.value = false;
|
||||
if (_onEnterOrLeaveImage4Toolbar != null) {
|
||||
try {
|
||||
_onEnterOrLeaveImage4Toolbar!(false);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
// See [onWindowBlur].
|
||||
if (!isWindows) {
|
||||
_ffi.inputModel.enterOrLeave(false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRawTouchAndPointerRegion(
|
||||
Widget child,
|
||||
PointerEnterEventListener? onEnter,
|
||||
PointerExitEventListener? onExit,
|
||||
) {
|
||||
return RawTouchGestureDetectorRegion(
|
||||
child: _buildRawPointerMouseRegion(child, onEnter, onExit),
|
||||
ffi: _ffi,
|
||||
isCamera: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRawPointerMouseRegion(
|
||||
Widget child,
|
||||
PointerEnterEventListener? onEnter,
|
||||
PointerExitEventListener? onExit,
|
||||
) {
|
||||
return CameraRawPointerMouseRegion(
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
onPointerDown: (event) {
|
||||
// A double check for blur status.
|
||||
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
||||
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
||||
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
||||
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
||||
if (_isWindowBlur) {
|
||||
debugPrint(
|
||||
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
||||
_isWindowBlur = false;
|
||||
}
|
||||
if (!_rawKeyFocusNode.hasFocus) {
|
||||
_rawKeyFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
inputModel: _ffi.inputModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBodyForDesktop(BuildContext context) {
|
||||
var paints = <Widget>[
|
||||
MouseRegion(onEnter: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
||||
}, onExit: (evt) {
|
||||
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
||||
}, child: LayoutBuilder(builder: (context, constraints) {
|
||||
final c = Provider.of<CanvasModel>(context, listen: false);
|
||||
Future.delayed(Duration.zero, () => c.updateViewStyle());
|
||||
final peerDisplay = CurrentDisplayState.find(widget.id);
|
||||
return Obx(
|
||||
() => _ffi.ffiModel.pi.isSet.isFalse
|
||||
? Container(color: Colors.transparent)
|
||||
: Obx(() {
|
||||
widget.toolbarState.initShow(sessionId);
|
||||
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
||||
return ImagePaint(
|
||||
id: widget.id,
|
||||
cursorOverImage: _cursorOverImage,
|
||||
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
|
||||
child, enterView, leaveView),
|
||||
ffi: _ffi,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}))
|
||||
];
|
||||
|
||||
paints.add(
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: _buildRawTouchAndPointerRegion(
|
||||
QualityMonitor(_ffi.qualityMonitorModel), null, null),
|
||||
),
|
||||
);
|
||||
return Stack(
|
||||
children: paints,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class ImagePaint extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final String id;
|
||||
final RxBool cursorOverImage;
|
||||
final Widget Function(Widget)? listenerBuilder;
|
||||
|
||||
ImagePaint(
|
||||
{Key? key,
|
||||
required this.ffi,
|
||||
required this.id,
|
||||
required this.cursorOverImage,
|
||||
this.listenerBuilder})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ImagePaintState();
|
||||
}
|
||||
|
||||
class _ImagePaintState extends State<ImagePaint> {
|
||||
bool _lastRemoteCursorMoved = false;
|
||||
|
||||
String get id => widget.id;
|
||||
RxBool get cursorOverImage => widget.cursorOverImage;
|
||||
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
var c = Provider.of<CanvasModel>(context);
|
||||
final s = c.scale;
|
||||
|
||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||
|
||||
if (c.imageOverflow.isTrue && c.scrollStyle == ScrollStyle.scrollbar) {
|
||||
final paintWidth = c.getDisplayWidth() * s;
|
||||
final paintHeight = c.getDisplayHeight() * s;
|
||||
final paintSize = Size(paintWidth, paintHeight);
|
||||
final paintWidget =
|
||||
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
||||
? _BuildPaintTextureRender(
|
||||
c, s, Offset.zero, paintSize, isViewOriginal())
|
||||
: _buildScrollbarNonTextureRender(m, paintSize, s);
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
c.updateScrollPercent();
|
||||
return false;
|
||||
},
|
||||
child: Container(
|
||||
child: _buildCrossScrollbarFromLayout(
|
||||
context,
|
||||
_buildListener(paintWidget),
|
||||
c.size,
|
||||
paintSize,
|
||||
c.scrollHorizontal,
|
||||
c.scrollVertical,
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
if (c.size.width > 0 && c.size.height > 0) {
|
||||
final paintWidget =
|
||||
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
||||
? _BuildPaintTextureRender(
|
||||
c,
|
||||
s,
|
||||
Offset(
|
||||
isLinux ? c.x.toInt().toDouble() : c.x,
|
||||
isLinux ? c.y.toInt().toDouble() : c.y,
|
||||
),
|
||||
c.size,
|
||||
isViewOriginal())
|
||||
: _buildScrollAutoNonTextureRender(m, c, s);
|
||||
return Container(child: _buildListener(paintWidget));
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScrollbarNonTextureRender(
|
||||
ImageModel m, Size imageSize, double s) {
|
||||
return CustomPaint(
|
||||
size: imageSize,
|
||||
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollAutoNonTextureRender(
|
||||
ImageModel m, CanvasModel c, double s) {
|
||||
return CustomPaint(
|
||||
size: Size(c.size.width, c.size.height),
|
||||
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _BuildPaintTextureRender(
|
||||
CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) {
|
||||
final ffiModel = c.parent.target!.ffiModel;
|
||||
final displays = ffiModel.pi.getCurDisplays();
|
||||
final children = <Widget>[];
|
||||
final rect = ffiModel.rect;
|
||||
if (rect == null) {
|
||||
return Container();
|
||||
}
|
||||
final curDisplay = ffiModel.pi.currentDisplay;
|
||||
for (var i = 0; i < displays.length; i++) {
|
||||
final textureId = widget.ffi.textureModel
|
||||
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
||||
if (true) {
|
||||
// both "textureId.value != -1" and "true" seems ok
|
||||
children.add(Positioned(
|
||||
left: (displays[i].x - rect.left) * s + offset.dx,
|
||||
top: (displays[i].y - rect.top) * s + offset.dy,
|
||||
width: displays[i].width * s,
|
||||
height: displays[i].height * s,
|
||||
child: Obx(() => Texture(
|
||||
textureId: textureId.value,
|
||||
filterQuality:
|
||||
isViewOriginal ? FilterQuality.none : FilterQuality.low,
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
return SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Stack(children: children),
|
||||
);
|
||||
}
|
||||
|
||||
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = cursor.cache ?? preDefaultCursor.cache;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
|
||||
final cursor = Provider.of<CursorModel>(context);
|
||||
final cache = preForbiddenCursor.cache;
|
||||
return buildCursorOfCache(cursor, scale, cache);
|
||||
}
|
||||
|
||||
Widget _buildCrossScrollbarFromLayout(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
Size layoutSize,
|
||||
Size size,
|
||||
ScrollController horizontal,
|
||||
ScrollController vertical,
|
||||
) {
|
||||
var widget = child;
|
||||
if (layoutSize.width < size.width) {
|
||||
widget = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
controller: horizontal,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: cursorOverImage.isTrue
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
child: widget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = Row(
|
||||
children: [
|
||||
Container(
|
||||
width: ((layoutSize.width - size.width) ~/ 2).toDouble(),
|
||||
),
|
||||
widget,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (layoutSize.height < size.height) {
|
||||
widget = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: SingleChildScrollView(
|
||||
controller: vertical,
|
||||
physics: cursorOverImage.isTrue
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: null,
|
||||
child: widget,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = Column(
|
||||
children: [
|
||||
Container(
|
||||
height: ((layoutSize.height - size.height) ~/ 2).toDouble(),
|
||||
),
|
||||
widget,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (layoutSize.width < size.width) {
|
||||
widget = RawScrollbar(
|
||||
thickness: kScrollbarThickness,
|
||||
thumbColor: Colors.grey,
|
||||
controller: horizontal,
|
||||
thumbVisibility: false,
|
||||
trackVisibility: false,
|
||||
notificationPredicate: layoutSize.height < size.height
|
||||
? (notification) => notification.depth == 1
|
||||
: defaultScrollNotificationPredicate,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
if (layoutSize.height < size.height) {
|
||||
widget = RawScrollbar(
|
||||
thickness: kScrollbarThickness,
|
||||
thumbColor: Colors.grey,
|
||||
controller: vertical,
|
||||
thumbVisibility: false,
|
||||
trackVisibility: false,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
child: widget,
|
||||
width: layoutSize.width,
|
||||
height: layoutSize.height,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListener(Widget child) {
|
||||
if (listenerBuilder != null) {
|
||||
return listenerBuilder!(child);
|
||||
} else {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
499
flutter/lib/desktop/pages/view_camera_tab_page.dart
Normal file
499
flutter/lib/desktop/pages/view_camera_tab_page.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
|
||||
as mod_menu;
|
||||
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
class _MenuTheme {
|
||||
static const Color blueColor = MyTheme.button;
|
||||
// kMinInteractiveDimension
|
||||
static const double height = 20.0;
|
||||
static const double dividerHeight = 12.0;
|
||||
}
|
||||
|
||||
class ViewCameraTabPage extends StatefulWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
const ViewCameraTabPage({Key? key, required this.params}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ViewCameraTabPage> createState() => _ViewCameraTabPageState(params);
|
||||
}
|
||||
|
||||
class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
|
||||
final tabController =
|
||||
Get.put(DesktopTabController(tabType: DesktopTabType.viewCamera));
|
||||
final contentKey = UniqueKey();
|
||||
static const IconData selectedIcon = Icons.desktop_windows_sharp;
|
||||
static const IconData unselectedIcon = Icons.desktop_windows_outlined;
|
||||
|
||||
String? peerId;
|
||||
bool _isScreenRectSet = false;
|
||||
int? _display;
|
||||
|
||||
var connectionMap = RxList<Widget>.empty(growable: true);
|
||||
|
||||
_ViewCameraTabPageState(Map<String, dynamic> params) {
|
||||
RemoteCountState.init();
|
||||
peerId = params['id'];
|
||||
final sessionId = params['session_id'];
|
||||
final tabWindowId = params['tab_window_id'];
|
||||
final display = params['display'];
|
||||
final displays = params['displays'];
|
||||
final screenRect = parseParamScreenRect(params);
|
||||
_isScreenRectSet = screenRect != null;
|
||||
_display = display as int?;
|
||||
tryMoveToScreenAndSetFullscreen(screenRect);
|
||||
if (peerId != null) {
|
||||
ConnectionTypeState.init(peerId!);
|
||||
tabController.onSelected = (id) {
|
||||
final viewCameraPage = tabController.widget(id);
|
||||
if (viewCameraPage is ViewCameraPage) {
|
||||
final ffi = viewCameraPage.ffi;
|
||||
bind.setCurSessionId(sessionId: ffi.sessionId);
|
||||
}
|
||||
WindowController.fromWindowId(params['windowId'])
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
UnreadChatCountState.find(id).value = 0;
|
||||
};
|
||||
tabController.add(TabInfo(
|
||||
key: peerId!,
|
||||
label: peerId!,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(peerId),
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(peerId),
|
||||
id: peerId!,
|
||||
sessionId: sessionId == null ? null : SessionID(sessionId),
|
||||
tabWindowId: tabWindowId,
|
||||
display: display,
|
||||
displays: displays?.cast<int>(),
|
||||
password: params['password'],
|
||||
toolbarState: ToolbarState(),
|
||||
tabController: tabController,
|
||||
connToken: params['connToken'],
|
||||
forceRelay: params['forceRelay'],
|
||||
isSharedPassword: params['isSharedPassword'],
|
||||
),
|
||||
));
|
||||
_update_remote_count();
|
||||
}
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (!_isScreenRectSet) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
restoreWindowPosition(
|
||||
WindowType.ViewCamera,
|
||||
windowId: windowId(),
|
||||
peerId: tabController.state.value.tabs.isEmpty
|
||||
? null
|
||||
: tabController.state.value.tabs[0].key,
|
||||
display: _display,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
body: DesktopTab(
|
||||
controller: tabController,
|
||||
onWindowCloseButton: handleWindowCloseButton,
|
||||
tail: const AddButton(),
|
||||
selectedBorderColor: MyTheme.accent,
|
||||
pageViewBuilder: (pageView) => pageView,
|
||||
labelGetter: DesktopTab.tablabelGetter,
|
||||
tabBuilder: (key, icon, label, themeConf) => Obx(() {
|
||||
final connectionType = ConnectionTypeState.find(key);
|
||||
if (!connectionType.isValid()) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon,
|
||||
label,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
bool secure =
|
||||
connectionType.secure.value == ConnectionType.strSecure;
|
||||
bool direct =
|
||||
connectionType.direct.value == ConnectionType.strDirect;
|
||||
String msgConn;
|
||||
if (secure && direct) {
|
||||
msgConn = translate("Direct and encrypted connection");
|
||||
} else if (secure && !direct) {
|
||||
msgConn = translate("Relayed and encrypted connection");
|
||||
} else if (!secure && direct) {
|
||||
msgConn = translate("Direct and unencrypted connection");
|
||||
} else {
|
||||
msgConn = translate("Relayed and unencrypted connection");
|
||||
}
|
||||
var msgFingerprint = '${translate('Fingerprint')}:\n';
|
||||
var fingerprint = FingerprintState.find(key).value;
|
||||
if (fingerprint.isEmpty) {
|
||||
fingerprint = 'N/A';
|
||||
}
|
||||
if (fingerprint.length > 5 * 8) {
|
||||
var first = fingerprint.substring(0, 39);
|
||||
var second = fingerprint.substring(40);
|
||||
msgFingerprint += '$first\n$second';
|
||||
} else {
|
||||
msgFingerprint += fingerprint;
|
||||
}
|
||||
|
||||
final tab = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
icon,
|
||||
Tooltip(
|
||||
message: '$msgConn\n$msgFingerprint',
|
||||
child: SvgPicture.asset(
|
||||
'assets/${connectionType.secure.value}${connectionType.direct.value}.svg',
|
||||
width: themeConf.iconSize,
|
||||
height: themeConf.iconSize,
|
||||
).paddingOnly(right: 5),
|
||||
),
|
||||
label,
|
||||
unreadMessageCountBuilder(UnreadChatCountState.find(key))
|
||||
.marginOnly(left: 4),
|
||||
],
|
||||
);
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (e) {
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
return;
|
||||
}
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == key)
|
||||
.page as ViewCameraPage;
|
||||
if (viewCameraPage.ffi.ffiModel.pi.isSet.isTrue &&
|
||||
e.buttons == 2) {
|
||||
showRightMenu(
|
||||
(CancelFunc cancelFunc) {
|
||||
return _tabMenuBuilder(key, cancelFunc);
|
||||
},
|
||||
target: e.position,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: tab,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
final tabWidget = isLinux
|
||||
? buildVirtualWindowFrame(context, child)
|
||||
: workaroundWindowBorder(
|
||||
context,
|
||||
Obx(() => Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: MyTheme.color(context).border!,
|
||||
width: stateGlobal.windowBorderWidth.value),
|
||||
),
|
||||
child: child,
|
||||
)));
|
||||
return isMacOS || kUseCompatibleUiMode
|
||||
? tabWidget
|
||||
: Obx(() => SubWindowDragToResizeArea(
|
||||
key: contentKey,
|
||||
child: tabWidget,
|
||||
// Specially configured for a better resize area and remote control.
|
||||
childPadding: kDragToResizeAreaPadding,
|
||||
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
|
||||
enableResizeEdges: subWindowManagerEnableResizeEdges,
|
||||
windowId: stateGlobal.windowId,
|
||||
));
|
||||
}
|
||||
|
||||
// Note: Some dup code to ../widgets/remote_toolbar
|
||||
Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) {
|
||||
final List<MenuEntryBase<String>> menu = [];
|
||||
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == key)
|
||||
.page as ViewCameraPage;
|
||||
final ffi = viewCameraPage.ffi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final toolbarState = viewCameraPage.toolbarState;
|
||||
menu.addAll([
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Obx(() => Text(
|
||||
translate(
|
||||
toolbarState.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
||||
style: style,
|
||||
)),
|
||||
proc: () {
|
||||
toolbarState.switchShow(sessionId);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
),
|
||||
]);
|
||||
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
final splitAction = MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Move tab to new window'),
|
||||
style: style,
|
||||
),
|
||||
proc: () async {
|
||||
await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId,
|
||||
kWindowEventMoveTabToNewWindow,
|
||||
'${windowId()},$key,$sessionId,ViewCamera');
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
);
|
||||
menu.insert(1, splitAction);
|
||||
}
|
||||
|
||||
menu.addAll([
|
||||
MenuEntryDivider<String>(),
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Copy Fingerprint'),
|
||||
style: style,
|
||||
),
|
||||
proc: () => onCopyFingerprint(FingerprintState.find(key).value),
|
||||
padding: padding,
|
||||
dismissOnClicked: true,
|
||||
dismissCallback: cancelFunc,
|
||||
),
|
||||
MenuEntryButton<String>(
|
||||
childBuilder: (TextStyle? style) => Text(
|
||||
translate('Close'),
|
||||
style: style,
|
||||
),
|
||||
proc: () {
|
||||
tabController.closeBy(key);
|
||||
cancelFunc();
|
||||
},
|
||||
padding: padding,
|
||||
)
|
||||
]);
|
||||
|
||||
return mod_menu.PopupMenu<String>(
|
||||
items: menu
|
||||
.map((entry) => entry.build(
|
||||
context,
|
||||
const MenuConfig(
|
||||
commonColor: _MenuTheme.blueColor,
|
||||
height: _MenuTheme.height,
|
||||
dividerHeight: _MenuTheme.dividerHeight,
|
||||
)))
|
||||
.expand((i) => i)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void onRemoveId(String id) async {
|
||||
if (tabController.state.value.tabs.isEmpty) {
|
||||
// Keep calling until the window status is hidden.
|
||||
//
|
||||
// Workaround for Windows:
|
||||
// If you click other buttons and close in msgbox within a very short period of time, the close may fail.
|
||||
// `await WindowController.fromWindowId(windowId()).close();`.
|
||||
Future<void> loopCloseWindow() async {
|
||||
int c = 0;
|
||||
final windowController = WindowController.fromWindowId(windowId());
|
||||
while (c < 20 &&
|
||||
tabController.state.value.tabs.isEmpty &&
|
||||
(!await windowController.isHidden())) {
|
||||
await windowController.close();
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
c++;
|
||||
}
|
||||
}
|
||||
|
||||
loopCloseWindow();
|
||||
}
|
||||
ConnectionTypeState.delete(id);
|
||||
_update_remote_count();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
||||
Future<bool> handleWindowCloseButton() async {
|
||||
final connLength = tabController.length;
|
||||
if (connLength <= 1) {
|
||||
tabController.clear();
|
||||
return true;
|
||||
} else {
|
||||
final bool res;
|
||||
if (!option2bool(kOptionEnableConfirmClosingTabs,
|
||||
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
|
||||
res = true;
|
||||
} else {
|
||||
res = await closeConfirmDialog();
|
||||
}
|
||||
if (res) {
|
||||
tabController.clear();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
_update_remote_count() =>
|
||||
RemoteCountState.find().value = tabController.length;
|
||||
|
||||
Future<dynamic> _remoteMethodHandler(call, fromWindowId) async {
|
||||
debugPrint(
|
||||
"[View Camera Page] call ${call.method} with args ${call.arguments} from window $fromWindowId");
|
||||
|
||||
dynamic returnValue;
|
||||
// for simplify, just replace connectionId
|
||||
if (call.method == kWindowEventNewViewCamera) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final sessionId = args['session_id'];
|
||||
final tabWindowId = args['tab_window_id'];
|
||||
final display = args['display'];
|
||||
final displays = args['displays'];
|
||||
final screenRect = parseParamScreenRect(args);
|
||||
final prePeerCount = tabController.length;
|
||||
Future.delayed(Duration.zero, () async {
|
||||
if (stateGlobal.fullscreen.isTrue) {
|
||||
await WindowController.fromWindowId(windowId()).setFullscreen(false);
|
||||
stateGlobal.setFullscreen(false, procWnd: false);
|
||||
}
|
||||
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
|
||||
WindowType.ViewCamera, display, screenRect);
|
||||
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
|
||||
await windowOnTop(windowId());
|
||||
});
|
||||
});
|
||||
ConnectionTypeState.init(id);
|
||||
tabController.add(TabInfo(
|
||||
key: id,
|
||||
label: id,
|
||||
selectedIcon: selectedIcon,
|
||||
unselectedIcon: unselectedIcon,
|
||||
onTabCloseButton: () => tabController.closeBy(id),
|
||||
page: ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
sessionId: sessionId == null ? null : SessionID(sessionId),
|
||||
tabWindowId: tabWindowId,
|
||||
display: display,
|
||||
displays: displays?.cast<int>(),
|
||||
password: args['password'],
|
||||
toolbarState: ToolbarState(),
|
||||
tabController: tabController,
|
||||
connToken: args['connToken'],
|
||||
forceRelay: args['forceRelay'],
|
||||
isSharedPassword: args['isSharedPassword'],
|
||||
),
|
||||
));
|
||||
} else if (call.method == kWindowDisableGrabKeyboard) {
|
||||
// ???
|
||||
} else if (call.method == "onDestroy") {
|
||||
tabController.clear();
|
||||
} else if (call.method == kWindowActionRebuild) {
|
||||
reloadCurrentWindow();
|
||||
} else if (call.method == kWindowEventActiveSession) {
|
||||
final jumpOk = tabController.jumpToByKey(call.arguments);
|
||||
if (jumpOk) {
|
||||
windowOnTop(windowId());
|
||||
}
|
||||
return jumpOk;
|
||||
} else if (call.method == kWindowEventActiveDisplaySession) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final display = args['display'];
|
||||
final jumpOk =
|
||||
tabController.jumpToByKeyAndDisplay(id, display, isCamera: true);
|
||||
if (jumpOk) {
|
||||
windowOnTop(windowId());
|
||||
}
|
||||
return jumpOk;
|
||||
} else if (call.method == kWindowEventGetRemoteList) {
|
||||
return tabController.state.value.tabs
|
||||
.map((e) => e.key)
|
||||
.toList()
|
||||
.join(',');
|
||||
} else if (call.method == kWindowEventGetSessionIdList) {
|
||||
return tabController.state.value.tabs
|
||||
.map((e) => '${e.key},${(e.page as ViewCameraPage).ffi.sessionId}')
|
||||
.toList()
|
||||
.join(';');
|
||||
} else if (call.method == kWindowEventGetCachedSessionData) {
|
||||
// Ready to show new window and close old tab.
|
||||
final args = jsonDecode(call.arguments);
|
||||
final id = args['id'];
|
||||
final close = args['close'];
|
||||
try {
|
||||
final viewCameraPage = tabController.state.value.tabs
|
||||
.firstWhere((tab) => tab.key == id)
|
||||
.page as ViewCameraPage;
|
||||
returnValue = viewCameraPage.ffi.ffiModel.cachedPeerData.toString();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get cached session data: $e');
|
||||
}
|
||||
if (close && returnValue != null) {
|
||||
closeSessionOnDispose[id] = false;
|
||||
tabController.closeBy(id);
|
||||
}
|
||||
} else if (call.method == kWindowEventRemoteWindowCoords) {
|
||||
final viewCameraPage =
|
||||
tabController.state.value.selectedTabInfo.page as ViewCameraPage;
|
||||
final ffi = viewCameraPage.ffi;
|
||||
final displayRect = ffi.ffiModel.displaysRect();
|
||||
if (displayRect != null) {
|
||||
final wc = WindowController.fromWindowId(windowId());
|
||||
Rect? frame;
|
||||
try {
|
||||
frame = await wc.getFrame();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"Failed to get frame of window $windowId, it may be hidden");
|
||||
}
|
||||
if (frame != null) {
|
||||
ffi.cursorModel.moveLocal(0, 0);
|
||||
final coords = RemoteWindowCoords(
|
||||
frame,
|
||||
CanvasCoords.fromCanvasModel(ffi.canvasModel),
|
||||
CursorCoords.fromCursorModel(ffi.cursorModel),
|
||||
displayRect);
|
||||
returnValue = jsonEncode(coords.toJson());
|
||||
}
|
||||
}
|
||||
} else if (call.method == kWindowEventSetFullscreen) {
|
||||
stateGlobal.setFullscreen(call.arguments == 'true');
|
||||
}
|
||||
_update_remote_count();
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user