mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-04-06 12:51: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:
@@ -29,8 +29,10 @@ import '../consts.dart';
|
||||
import 'common/widgets/overlay.dart';
|
||||
import 'mobile/pages/file_manager_page.dart';
|
||||
import 'mobile/pages/remote_page.dart';
|
||||
import 'mobile/pages/view_camera_page.dart';
|
||||
import 'desktop/pages/remote_page.dart' as desktop_remote;
|
||||
import 'desktop/pages/file_manager_page.dart' as desktop_file_manager;
|
||||
import 'desktop/pages/view_camera_page.dart' as desktop_view_camera;
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'models/model.dart';
|
||||
import 'models/platform_model.dart';
|
||||
@@ -96,6 +98,7 @@ enum DesktopType {
|
||||
main,
|
||||
remote,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
cm,
|
||||
portForward,
|
||||
}
|
||||
@@ -1750,7 +1753,8 @@ Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
||||
await bind.setLocalFlutterOption(
|
||||
k: windowFramePrefix + type.name, v: pos.toString());
|
||||
|
||||
if (type == WindowType.RemoteDesktop && windowId != null) {
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null) {
|
||||
await _saveSessionWindowPosition(
|
||||
type, windowId, isMaximized, isFullscreen, pos);
|
||||
}
|
||||
@@ -1901,7 +1905,9 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
String? pos;
|
||||
// No need to check mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs)
|
||||
// Though "open in tabs" is true and the new window restore peer position, it's ok.
|
||||
if (type == WindowType.RemoteDesktop && windowId != null && peerId != null) {
|
||||
if ((type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) &&
|
||||
windowId != null &&
|
||||
peerId != null) {
|
||||
final peerPos = bind.mainGetPeerFlutterOptionSync(
|
||||
id: peerId, k: windowFramePrefix + type.name);
|
||||
if (peerPos.isNotEmpty) {
|
||||
@@ -1916,7 +1922,7 @@ Future<bool> restoreWindowPosition(WindowType type,
|
||||
debugPrint("no window position saved, ignoring position restoration");
|
||||
return false;
|
||||
}
|
||||
if (type == WindowType.RemoteDesktop) {
|
||||
if (type == WindowType.RemoteDesktop || type == WindowType.ViewCamera) {
|
||||
if (!isRemotePeerPos && windowId != null) {
|
||||
if (lpos.offsetWidth != null) {
|
||||
lpos.offsetWidth = lpos.offsetWidth! + windowId * kNewWindowOffset;
|
||||
@@ -2085,6 +2091,7 @@ StreamSubscription? listenUniLinks({handleByFlutter = true}) {
|
||||
enum UriLinkType {
|
||||
remoteDesktop,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
rdp,
|
||||
}
|
||||
@@ -2136,6 +2143,11 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--view-camera':
|
||||
type = UriLinkType.viewCamera;
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--port-forward':
|
||||
type = UriLinkType.portForward;
|
||||
id = args[i + 1];
|
||||
@@ -2177,6 +2189,12 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
password: password, forceRelay: forceRelay);
|
||||
});
|
||||
break;
|
||||
case UriLinkType.viewCamera:
|
||||
Future.delayed(Duration.zero, () {
|
||||
rustDeskWinManager.newViewCamera(id!,
|
||||
password: password, forceRelay: forceRelay);
|
||||
});
|
||||
break;
|
||||
case UriLinkType.portForward:
|
||||
Future.delayed(Duration.zero, () {
|
||||
rustDeskWinManager.newPortForward(id!, false,
|
||||
@@ -2200,7 +2218,14 @@ bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
||||
List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
String? command;
|
||||
String? id;
|
||||
final options = ["connect", "play", "file-transfer", "port-forward", "rdp"];
|
||||
final options = [
|
||||
"connect",
|
||||
"play",
|
||||
"file-transfer",
|
||||
"view-camera",
|
||||
"port-forward",
|
||||
"rdp"
|
||||
];
|
||||
if (uri.authority.isEmpty &&
|
||||
uri.path.split('').every((char) => char == '/')) {
|
||||
return [];
|
||||
@@ -2238,6 +2263,8 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
connect(Get.context!, id);
|
||||
} else if (optionIndex == 2) {
|
||||
connect(Get.context!, id, isFileTransfer: true);
|
||||
} else if (optionIndex == 3) {
|
||||
connect(Get.context!, id, isViewCamera: true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2290,6 +2317,7 @@ List<String>? urlLinkToCmdArgs(Uri uri) {
|
||||
|
||||
connectMainDesktop(String id,
|
||||
{required bool isFileTransfer,
|
||||
required bool isViewCamera,
|
||||
required bool isTcpTunneling,
|
||||
required bool isRDP,
|
||||
bool? forceRelay,
|
||||
@@ -2302,6 +2330,12 @@ connectMainDesktop(String id,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
forceRelay: forceRelay);
|
||||
} else if (isViewCamera) {
|
||||
await rustDeskWinManager.newViewCamera(id,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
forceRelay: forceRelay);
|
||||
} else if (isTcpTunneling || isRDP) {
|
||||
await rustDeskWinManager.newPortForward(id, isRDP,
|
||||
password: password,
|
||||
@@ -2318,10 +2352,12 @@ connectMainDesktop(String id,
|
||||
|
||||
/// Connect to a peer with [id].
|
||||
/// If [isFileTransfer], starts a session only for file transfer.
|
||||
/// If [isViewCamera], starts a session only for view camera.
|
||||
/// If [isTcpTunneling], starts a session only for tcp tunneling.
|
||||
/// If [isRDP], starts a session only for rdp.
|
||||
connect(BuildContext context, String id,
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false,
|
||||
bool forceRelay = false,
|
||||
@@ -2353,6 +2389,7 @@ connect(BuildContext context, String id,
|
||||
await connectMainDesktop(
|
||||
id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP,
|
||||
password: password,
|
||||
@@ -2363,6 +2400,7 @@ connect(BuildContext context, String id,
|
||||
await rustDeskWinManager.call(WindowType.Main, kWindowConnect, {
|
||||
'id': id,
|
||||
'isFileTransfer': isFileTransfer,
|
||||
'isViewCamera': isViewCamera,
|
||||
'isTcpTunneling': isTcpTunneling,
|
||||
'isRDP': isRDP,
|
||||
'password': password,
|
||||
@@ -2400,6 +2438,31 @@ connect(BuildContext context, String id,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (isViewCamera) {
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) =>
|
||||
desktop_view_camera.ViewCameraPage(
|
||||
key: ValueKey(id),
|
||||
id: id,
|
||||
toolbarState: ToolbarState(),
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
isSharedPassword: isSharedPassword,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) => ViewCameraPage(
|
||||
id: id, password: password, isSharedPassword: isSharedPassword),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
@@ -2686,6 +2749,8 @@ String getWindowName({WindowType? overrideType}) {
|
||||
return name;
|
||||
case WindowType.FileTransfer:
|
||||
return "File Transfer - $name";
|
||||
case WindowType.ViewCamera:
|
||||
return "View Camera - $name";
|
||||
case WindowType.PortForward:
|
||||
return "Port Forward - $name";
|
||||
case WindowType.RemoteDesktop:
|
||||
@@ -3051,6 +3116,7 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
||||
'peer_id': peerId,
|
||||
'display': i,
|
||||
'display_count': pi.displays.length,
|
||||
'window_type': (kWindowType ?? WindowType.RemoteDesktop).index,
|
||||
};
|
||||
if (screenRect != null) {
|
||||
args['screen_rect'] = {
|
||||
@@ -3065,12 +3131,12 @@ openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
||||
}
|
||||
|
||||
setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
|
||||
int? display, Rect? screenRect) async {
|
||||
WindowType windowType, int? display, Rect? screenRect) async {
|
||||
if (screenRect == null) {
|
||||
// Do not restore window position to new connection if there's a pre-session.
|
||||
// https://github.com/rustdesk/rustdesk/discussions/8825
|
||||
if (preSessionCount == 0) {
|
||||
await restoreWindowPosition(WindowType.RemoteDesktop,
|
||||
await restoreWindowPosition(windowType,
|
||||
windowId: windowId, display: display, peerId: peerId);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -488,6 +488,7 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
BuildContext context,
|
||||
String title, {
|
||||
bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false,
|
||||
}) {
|
||||
@@ -502,6 +503,7 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
peer,
|
||||
tab,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP,
|
||||
);
|
||||
@@ -530,6 +532,15 @@ abstract class BasePeerCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _viewCameraAction(BuildContext context) {
|
||||
return _connectCommonAction(
|
||||
context,
|
||||
translate('View camera'),
|
||||
isViewCamera: true,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
MenuEntryBase<String> _tcpTunnelingAction(BuildContext context) {
|
||||
return _connectCommonAction(
|
||||
@@ -880,6 +891,7 @@ class RecentPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
|
||||
final List favs = (await bind.mainGetFav()).toList();
|
||||
@@ -939,6 +951,7 @@ class FavoritePeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -992,6 +1005,7 @@ class DiscoveredPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
|
||||
final List favs = (await bind.mainGetFav()).toList();
|
||||
@@ -1045,6 +1059,7 @@ class AddressBookPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -1177,6 +1192,7 @@ class MyGroupPeerCard extends BasePeerCard {
|
||||
final List<MenuEntryBase<String>> menuItems = [
|
||||
_connectAction(context),
|
||||
_transferFileAction(context),
|
||||
_viewCameraAction(context),
|
||||
];
|
||||
if (isDesktop && peer.platform != kPeerPlatformAndroid) {
|
||||
menuItems.add(_tcpTunnelingAction(context));
|
||||
@@ -1398,6 +1414,7 @@ class TagPainter extends CustomPainter {
|
||||
|
||||
void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false,
|
||||
bool isRDP = false}) async {
|
||||
var password = '';
|
||||
@@ -1423,6 +1440,7 @@ void connectInPeerTab(BuildContext context, Peer peer, PeerTabIndex tab,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
isRDP: isRDP);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -53,13 +54,14 @@ class RawKeyFocusScope extends StatelessWidget {
|
||||
class RawTouchGestureDetectorRegion extends StatefulWidget {
|
||||
final Widget child;
|
||||
final FFI ffi;
|
||||
|
||||
final bool isCamera;
|
||||
late final InputModel inputModel = ffi.inputModel;
|
||||
late final FfiModel ffiModel = ffi.ffiModel;
|
||||
|
||||
RawTouchGestureDetectorRegion({
|
||||
required this.child,
|
||||
required this.ffi,
|
||||
this.isCamera = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -382,6 +384,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
_scale = d.scale;
|
||||
|
||||
if (scale != 0) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
@@ -402,6 +405,7 @@ class _RawTouchGestureDetectorRegionState
|
||||
return;
|
||||
}
|
||||
if ((isDesktop || isWebDesktop)) {
|
||||
if (widget.isCamera) return;
|
||||
await bind.sessionSendPointer(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(
|
||||
@@ -536,3 +540,46 @@ class RawPointerMouseRegion extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CameraRawPointerMouseRegion extends StatelessWidget {
|
||||
final InputModel inputModel;
|
||||
final Widget child;
|
||||
final PointerEnterEventListener? onEnter;
|
||||
final PointerExitEventListener? onExit;
|
||||
final PointerDownEventListener? onPointerDown;
|
||||
final PointerUpEventListener? onPointerUp;
|
||||
|
||||
CameraRawPointerMouseRegion({
|
||||
this.onEnter,
|
||||
this.onExit,
|
||||
this.onPointerDown,
|
||||
this.onPointerUp,
|
||||
required this.inputModel,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerHover: (evt) {
|
||||
final offset = evt.position;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
inputModel.handlePointerDevicePos(
|
||||
kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault);
|
||||
},
|
||||
onPointerDown: (evt) {
|
||||
onPointerDown?.call(evt);
|
||||
},
|
||||
onPointerUp: (evt) {
|
||||
onPointerUp?.call(evt);
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: MouseCursor.defer,
|
||||
onEnter: onEnter,
|
||||
onExit: onExit,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
List<TTextMenu> v = [];
|
||||
// elevation
|
||||
if (perms['keyboard'] != false && ffi.elevationModel.showRequestMenu) {
|
||||
if (isDefaultConn &&
|
||||
perms['keyboard'] != false &&
|
||||
ffi.elevationModel.showRequestMenu) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Request Elevation')),
|
||||
@@ -101,7 +104,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// osAccount / osPassword
|
||||
if (perms['keyboard'] != false) {
|
||||
if (isDefaultConn && perms['keyboard'] != false) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Row(children: [
|
||||
@@ -130,7 +133,9 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// paste
|
||||
if (pi.platform != kPeerPlatformAndroid && perms['keyboard'] != false) {
|
||||
if (isDefaultConn &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
perms['keyboard'] != false) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Send clipboard keystrokes')),
|
||||
onPressed: () async {
|
||||
@@ -142,43 +147,53 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}));
|
||||
}
|
||||
// reset canvas
|
||||
if (isMobile) {
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Reset canvas')),
|
||||
onPressed: () => ffi.cursorModel.reset()));
|
||||
}
|
||||
|
||||
connectWithToken(
|
||||
{required bool isFileTransfer, required bool isTcpTunneling}) {
|
||||
{bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isTcpTunneling = false}) {
|
||||
final connToken = bind.sessionGetConnToken(sessionId: ffi.sessionId);
|
||||
connect(context, id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isTcpTunneling: isTcpTunneling,
|
||||
connToken: connToken);
|
||||
}
|
||||
|
||||
// transferFile
|
||||
if (isDesktop) {
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Transfer file')),
|
||||
onPressed: () =>
|
||||
connectWithToken(isFileTransfer: true, isTcpTunneling: false)),
|
||||
onPressed: () => connectWithToken(isFileTransfer: true)),
|
||||
);
|
||||
}
|
||||
// viewCamera
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('View camera')),
|
||||
onPressed: () => connectWithToken(isViewCamera: true)),
|
||||
);
|
||||
}
|
||||
// tcpTunneling
|
||||
if (isDesktop) {
|
||||
if (isDefaultConn && isDesktop) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('TCP tunneling')),
|
||||
onPressed: () =>
|
||||
connectWithToken(isFileTransfer: false, isTcpTunneling: true)),
|
||||
onPressed: () => connectWithToken(isTcpTunneling: true)),
|
||||
);
|
||||
}
|
||||
// note
|
||||
if (bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
if (isDefaultConn &&
|
||||
bind
|
||||
.sessionGetAuditServerSync(sessionId: sessionId, typ: "conn")
|
||||
.isNotEmpty) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Note')),
|
||||
@@ -186,11 +201,12 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// divider
|
||||
if (isDesktop || isWebDesktop) {
|
||||
if (isDefaultConn && (isDesktop || isWebDesktop)) {
|
||||
v.add(TTextMenu(child: Offstage(), onPressed: () {}, divider: true));
|
||||
}
|
||||
// ctrlAltDel
|
||||
if (!ffiModel.viewOnly &&
|
||||
if (isDefaultConn &&
|
||||
!ffiModel.viewOnly &&
|
||||
ffiModel.keyboard &&
|
||||
(pi.platform == kPeerPlatformLinux || pi.sasEnabled)) {
|
||||
v.add(
|
||||
@@ -200,7 +216,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// restart
|
||||
if (perms['restart'] != false &&
|
||||
if (isDefaultConn &&
|
||||
perms['restart'] != false &&
|
||||
(pi.platform == kPeerPlatformLinux ||
|
||||
pi.platform == kPeerPlatformWindows ||
|
||||
pi.platform == kPeerPlatformMacOS)) {
|
||||
@@ -212,7 +229,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// insertLock
|
||||
if (!ffiModel.viewOnly && ffi.ffiModel.keyboard) {
|
||||
if (isDefaultConn && !ffiModel.viewOnly && ffi.ffiModel.keyboard) {
|
||||
v.add(
|
||||
TTextMenu(
|
||||
child: Text(translate('Insert Lock')),
|
||||
@@ -220,7 +237,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
);
|
||||
}
|
||||
// blockUserInput
|
||||
if (ffi.ffiModel.keyboard &&
|
||||
if (isDefaultConn &&
|
||||
ffi.ffiModel.keyboard &&
|
||||
ffi.ffiModel.permissions['block_input'] != false &&
|
||||
pi.platform == kPeerPlatformWindows) // privacy-mode != true ??
|
||||
{
|
||||
@@ -236,12 +254,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
}));
|
||||
}
|
||||
// switchSides
|
||||
if (isDesktop &&
|
||||
if (isDefaultConn &&
|
||||
isDesktop &&
|
||||
ffiModel.keyboard &&
|
||||
pi.platform != kPeerPlatformAndroid &&
|
||||
pi.platform != kPeerPlatformMacOS &&
|
||||
versionCmp(pi.version, '1.2.0') >= 0 &&
|
||||
bind.peerGetDefaultSessionsCount(id: id) == 1) {
|
||||
bind.peerGetSessionsCount(id: id, connType: ffi.connType.index) == 1) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Switch Sides')),
|
||||
onPressed: () =>
|
||||
@@ -523,6 +542,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final pi = ffiModel.pi;
|
||||
final perms = ffiModel.permissions;
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
// show quality monitor
|
||||
final option = 'show-quality-monitor';
|
||||
@@ -535,7 +555,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
},
|
||||
child: Text(translate('Show quality monitor'))));
|
||||
// mute
|
||||
if (perms['audio'] != false) {
|
||||
if (isDefaultConn && perms['audio'] != false) {
|
||||
final option = 'disable-audio';
|
||||
final value =
|
||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||
@@ -556,7 +576,8 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final isSupportIfPeer_1_2_4 = versionCmp(pi.version, '1.2.4') >= 0 &&
|
||||
bind.mainHasFileClipboard() &&
|
||||
pi.platformAdditions.containsKey(kPlatformAdditionsHasFileClipboard);
|
||||
if (ffiModel.keyboard &&
|
||||
if (isDefaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
perms['file'] != false &&
|
||||
(isSupportIfPeer_1_2_3 || isSupportIfPeer_1_2_4)) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
@@ -574,7 +595,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Enable file copy and paste'))));
|
||||
}
|
||||
// disable clipboard
|
||||
if (ffiModel.keyboard && perms['clipboard'] != false) {
|
||||
if (isDefaultConn && ffiModel.keyboard && perms['clipboard'] != false) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
final option = 'disable-clipboard';
|
||||
var value =
|
||||
@@ -591,7 +612,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('Disable clipboard'))));
|
||||
}
|
||||
// lock after session end
|
||||
if (ffiModel.keyboard && !ffiModel.isPeerAndroid) {
|
||||
if (isDefaultConn && ffiModel.keyboard && !ffiModel.isPeerAndroid) {
|
||||
final enabled = !ffiModel.viewOnly;
|
||||
final option = 'lock-after-session-end';
|
||||
final value =
|
||||
@@ -656,12 +677,12 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
child: Text(translate('True color (4:4:4)'))));
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.addAll(toolbarKeyboardToggles(ffi));
|
||||
}
|
||||
|
||||
// view mode (mobile only, desktop is in keyboard menu)
|
||||
if (isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
|
||||
if (isDefaultConn && isMobile && versionCmp(pi.version, '1.2.0') >= 0) {
|
||||
v.add(TToggleMenu(
|
||||
value: ffiModel.viewOnly,
|
||||
onChanged: (value) async {
|
||||
|
||||
@@ -27,6 +27,7 @@ const String kPlatformAdditionsAmyuniVirtualDisplays =
|
||||
const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
|
||||
const String kPlatformAdditionsSupportedPrivacyModeImpl =
|
||||
"supported_privacy_mode_impl";
|
||||
const String kPlatformAdditionsSupportViewCamera = "support_view_camera";
|
||||
|
||||
const String kPeerPlatformWindows = "Windows";
|
||||
const String kPeerPlatformLinux = "Linux";
|
||||
@@ -44,6 +45,7 @@ const String kAppTypeConnectionManager = "cm";
|
||||
|
||||
const String kAppTypeDesktopRemote = "remote";
|
||||
const String kAppTypeDesktopFileTransfer = "file transfer";
|
||||
const String kAppTypeDesktopViewCamera = "view camera";
|
||||
const String kAppTypeDesktopPortForward = "port forward";
|
||||
|
||||
const String kWindowMainWindowOnTop = "main_window_on_top";
|
||||
@@ -58,6 +60,7 @@ const String kWindowConnect = "connect";
|
||||
|
||||
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
|
||||
const String kWindowEventNewFileTransfer = "new_file_transfer";
|
||||
const String kWindowEventNewViewCamera = "new_view_camera";
|
||||
const String kWindowEventNewPortForward = "new_port_forward";
|
||||
const String kWindowEventActiveSession = "active_session";
|
||||
const String kWindowEventActiveDisplaySession = "active_display_session";
|
||||
@@ -97,6 +100,7 @@ const String kOptionEnableKeyboard = "enable-keyboard";
|
||||
const String kOptionEnableClipboard = "enable-clipboard";
|
||||
const String kOptionEnableFileTransfer = "enable-file-transfer";
|
||||
const String kOptionEnableAudio = "enable-audio";
|
||||
const String kOptionEnableCamera = "enable-camera";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
35
flutter/lib/desktop/screen/desktop_view_camera_screen.dart
Normal file
35
flutter/lib/desktop/screen/desktop_view_camera_screen.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_tab_page.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// multi-tab desktop remote screen
|
||||
class DesktopViewCameraScreen extends StatelessWidget {
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
DesktopViewCameraScreen({Key? key, required this.params}) : super(key: key) {
|
||||
bind.mainInitInputSource();
|
||||
stateGlobal.getInputSource(force: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: gFFI.ffiModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.imageModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.cursorModel),
|
||||
ChangeNotifierProvider.value(value: gFFI.canvasModel),
|
||||
],
|
||||
child: Scaffold(
|
||||
// Set transparent background for padding the resize area out of the flutter view.
|
||||
// This allows the wallpaper goes through our resize area. (Linux only now).
|
||||
backgroundColor: isLinux ? Colors.transparent : null,
|
||||
body: ViewCameraTabPage(
|
||||
params: params,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -478,7 +478,10 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
||||
state: widget.state,
|
||||
setFullscreen: _setFullscreen,
|
||||
));
|
||||
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
||||
// Do not show keyboard for camera connection type.
|
||||
if (widget.ffi.connType == ConnType.defaultConn) {
|
||||
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
||||
}
|
||||
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
|
||||
if (!isWeb) {
|
||||
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
|
||||
@@ -1043,23 +1046,26 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
scrollStyle(),
|
||||
imageQuality(),
|
||||
codec(),
|
||||
_ResolutionsMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
screenAdjustor: _screenAdjustor,
|
||||
),
|
||||
if (showVirtualDisplayMenu(ffi))
|
||||
if (ffi.connType == ConnType.defaultConn)
|
||||
_ResolutionsMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
screenAdjustor: _screenAdjustor,
|
||||
),
|
||||
if (showVirtualDisplayMenu(ffi) && ffi.connType == ConnType.defaultConn)
|
||||
_SubmenuButton(
|
||||
ffi: widget.ffi,
|
||||
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
|
||||
child: Text(translate("Virtual display")),
|
||||
),
|
||||
cursorToggles(),
|
||||
if (ffi.connType == ConnType.defaultConn) cursorToggles(),
|
||||
Divider(),
|
||||
toggles(),
|
||||
];
|
||||
// privacy mode
|
||||
if (ffiModel.keyboard && pi.features.privacyMode) {
|
||||
if (ffi.connType == ConnType.defaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
pi.features.privacyMode) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
final privacyModeList =
|
||||
toolbarPrivacyMode(privacyModeState, context, id, ffi);
|
||||
@@ -1085,7 +1091,9 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
menuChildren.add(widget.pluginItem);
|
||||
if (ffi.connType == ConnType.defaultConn) {
|
||||
menuChildren.add(widget.pluginItem);
|
||||
}
|
||||
return menuChildren;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:flutter/material.dart' hide TabBarTheme;
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
@@ -51,6 +52,7 @@ enum DesktopTabType {
|
||||
cm,
|
||||
remoteScreen,
|
||||
fileTransfer,
|
||||
viewCamera,
|
||||
portForward,
|
||||
install,
|
||||
}
|
||||
@@ -179,11 +181,13 @@ class DesktopTabController {
|
||||
jumpTo(state.value.tabs.indexWhere((tab) => tab.key == key),
|
||||
callOnSelected: callOnSelected);
|
||||
|
||||
bool jumpToByKeyAndDisplay(String key, int display) {
|
||||
bool jumpToByKeyAndDisplay(String key, int display, {bool isCamera = false}) {
|
||||
for (int i = 0; i < state.value.tabs.length; i++) {
|
||||
final tab = state.value.tabs[i];
|
||||
if (tab.key == key) {
|
||||
final ffi = (tab.page as RemotePage).ffi;
|
||||
final ffi = isCamera
|
||||
? (tab.page as ViewCameraPage).ffi
|
||||
: (tab.page as RemotePage).ffi;
|
||||
if (ffi.ffiModel.pi.currentDisplay == display) {
|
||||
return jumpTo(i, callOnSelected: true);
|
||||
}
|
||||
@@ -725,6 +729,7 @@ class WindowActionPanelState extends State<WindowActionPanel> {
|
||||
return widget.tabController.state.value.tabs.length > 1 &&
|
||||
(widget.tabController.tabType == DesktopTabType.remoteScreen ||
|
||||
widget.tabController.tabType == DesktopTabType.fileTransfer ||
|
||||
widget.tabController.tabType == DesktopTabType.viewCamera ||
|
||||
widget.tabController.tabType == DesktopTabType.portForward ||
|
||||
widget.tabController.tabType == DesktopTabType.cm);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/install_page.dart';
|
||||
import 'package:flutter_hbb/desktop/pages/server_page.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_view_camera_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
||||
@@ -76,6 +77,13 @@ Future<void> main(List<String> args) async {
|
||||
kAppTypeDesktopFileTransfer,
|
||||
);
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
desktopType = DesktopType.viewCamera;
|
||||
runMultiWindow(
|
||||
argument,
|
||||
kAppTypeDesktopViewCamera,
|
||||
);
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
desktopType = DesktopType.portForward;
|
||||
runMultiWindow(
|
||||
@@ -192,6 +200,12 @@ void runMultiWindow(
|
||||
params: argument,
|
||||
);
|
||||
break;
|
||||
case kAppTypeDesktopViewCamera:
|
||||
draggablePositions.load();
|
||||
widget = DesktopViewCameraScreen(
|
||||
params: argument,
|
||||
);
|
||||
break;
|
||||
case kAppTypeDesktopPortForward:
|
||||
widget = DesktopPortForwardScreen(
|
||||
params: argument,
|
||||
@@ -227,6 +241,19 @@ void runMultiWindow(
|
||||
await restoreWindowPosition(WindowType.FileTransfer,
|
||||
windowId: kWindowId!);
|
||||
break;
|
||||
case kAppTypeDesktopViewCamera:
|
||||
// If screen rect is set, the window will be moved to the target screen and then set fullscreen.
|
||||
if (argument['screen_rect'] == null) {
|
||||
// display can be used to control the offset of the window.
|
||||
await restoreWindowPosition(
|
||||
WindowType.ViewCamera,
|
||||
windowId: kWindowId!,
|
||||
peerId: argument['id'] as String?,
|
||||
// FIXME: fix display index.
|
||||
display: argument['display'] as int?,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case kAppTypeDesktopPortForward:
|
||||
await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!);
|
||||
break;
|
||||
|
||||
@@ -204,6 +204,7 @@ class WebHomePage extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
String? id;
|
||||
String? password;
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
@@ -219,6 +220,11 @@ class WebHomePage extends StatelessWidget {
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--view-camera':
|
||||
isViewCamera = true;
|
||||
id = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
case '--password':
|
||||
password = args[i + 1];
|
||||
i++;
|
||||
@@ -228,7 +234,7 @@ class WebHomePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
if (id != null) {
|
||||
connect(context, id, isFileTransfer: isFileTransfer, password: password);
|
||||
connect(context, id, isFileTransfer: isFileTransfer, isViewCamera: isViewCamera, password: password);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
721
flutter/lib/mobile/pages/view_camera_page.dart
Normal file
721
flutter/lib/mobile/pages/view_camera_page.dart
Normal file
@@ -0,0 +1,721 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/overlay.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../common/widgets/remote_input.dart';
|
||||
import '../../models/input_model.dart';
|
||||
import '../../models/model.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
import '../../utils/image.dart';
|
||||
|
||||
final initText = '1' * 1024;
|
||||
|
||||
// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
|
||||
// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
|
||||
// https://github.com/flutter/flutter/issues/159384
|
||||
// https://github.com/flutter/flutter/issues/159383
|
||||
void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
|
||||
if (isAndroid) {
|
||||
if (isKeyboardVisible != true) {
|
||||
// `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewCameraPage extends StatefulWidget {
|
||||
ViewCameraPage(
|
||||
{Key? key, required this.id, this.password, this.isSharedPassword})
|
||||
: super(key: key);
|
||||
|
||||
final String id;
|
||||
final String? password;
|
||||
final bool? isSharedPassword;
|
||||
|
||||
@override
|
||||
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
|
||||
}
|
||||
|
||||
class _ViewCameraPageState extends State<ViewCameraPage>
|
||||
with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
bool _showBar = !isWebDesktop;
|
||||
bool _showGestureHelp = false;
|
||||
Orientation? _currentOrientation;
|
||||
double _viewInsetsBottom = 0;
|
||||
|
||||
Timer? _timerDidChangeMetrics;
|
||||
|
||||
final _blockableOverlayState = BlockableOverlayState();
|
||||
|
||||
final keyboardVisibilityController = KeyboardVisibilityController();
|
||||
final FocusNode _mobileFocusNode = FocusNode();
|
||||
final FocusNode _physicalFocusNode = FocusNode();
|
||||
var _showEdit = false; // use soft keyboard
|
||||
|
||||
InputModel get inputModel => gFFI.inputModel;
|
||||
SessionID get sessionId => gFFI.sessionId;
|
||||
|
||||
final TextEditingController _textController =
|
||||
TextEditingController(text: initText);
|
||||
|
||||
_ViewCameraPageState(String id) {
|
||||
initSharedStates(id);
|
||||
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
|
||||
gFFI.dialogManager.loadMobileActionsOverlayVisible();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
|
||||
gFFI.start(
|
||||
widget.id,
|
||||
isViewCamera: true,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
gFFI.dialogManager
|
||||
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
||||
});
|
||||
if (!isWeb) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
_physicalFocusNode.requestFocus();
|
||||
gFFI.inputModel.listenToMouse(true);
|
||||
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
||||
gFFI.chatModel
|
||||
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
|
||||
_blockableOverlayState.applyFfi(gFFI);
|
||||
gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
|
||||
gFFI.recordingModel
|
||||
.updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
|
||||
if (gFFI.recordingModel.start) {
|
||||
showToast(translate('Automatically record outgoing sessions'));
|
||||
}
|
||||
_disableAndroidSoftKeyboard(
|
||||
isKeyboardVisible: keyboardVisibilityController.isVisible);
|
||||
});
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// https://github.com/flutter/flutter/issues/64935
|
||||
super.dispose();
|
||||
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
|
||||
gFFI.inputModel.listenToMouse(false);
|
||||
gFFI.imageModel.disposeImage();
|
||||
gFFI.cursorModel.disposeImages();
|
||||
await gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
_mobileFocusNode.dispose();
|
||||
_physicalFocusNode.dispose();
|
||||
await gFFI.close();
|
||||
_timer?.cancel();
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
gFFI.dialogManager.dismissAll();
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values);
|
||||
if (!isWeb) {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
removeSharedStates(widget.id);
|
||||
// `on_voice_call_closed` should be called when the connection is ended.
|
||||
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
|
||||
// Only one client is considered here for now.
|
||||
gFFI.chatModel.onVoiceCallClosed("End connetion");
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
|
||||
// Don't try reset the view style and focus the cursor.
|
||||
if (gFFI.cursorModel.lastKeyboardIsVisible &&
|
||||
gFFI.canvasModel.isMobileCanvasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
|
||||
_timerDidChangeMetrics?.cancel();
|
||||
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
|
||||
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
|
||||
if (newBottom != _viewInsetsBottom) {
|
||||
gFFI.canvasModel.mobileFocusCanvasCursor();
|
||||
_viewInsetsBottom = newBottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// to-do: It should be better to use transparent color instead of the bgColor.
|
||||
// But for now, the transparent color will cause the canvas to be white.
|
||||
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
|
||||
// But I don't know why and how to fix it.
|
||||
Widget emptyOverlay(Color bgColor) => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
state: _blockableOverlayState,
|
||||
underlying: Container(
|
||||
color: bgColor,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
|
||||
? getBottomAppBar()
|
||||
: Offstage());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardIsVisible =
|
||||
keyboardVisibilityController.isVisible && _showEdit;
|
||||
final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
// workaround for https://github.com/rustdesk/rustdesk/issues/3131
|
||||
floatingActionButtonLocation: keyboardIsVisible
|
||||
? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
|
||||
: null,
|
||||
floatingActionButton: !showActionButton
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
mini: !keyboardIsVisible,
|
||||
child: Icon(
|
||||
(keyboardIsVisible || _showGestureHelp)
|
||||
? Icons.expand_more
|
||||
: Icons.expand_less,
|
||||
color: Colors.white,
|
||||
),
|
||||
backgroundColor: MyTheme.accent,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (keyboardIsVisible) {
|
||||
_showEdit = false;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
_mobileFocusNode.unfocus();
|
||||
_physicalFocusNode.requestFocus();
|
||||
} else if (_showGestureHelp) {
|
||||
_showGestureHelp = false;
|
||||
} else {
|
||||
_showBar = !_showBar;
|
||||
}
|
||||
});
|
||||
}),
|
||||
bottomNavigationBar: Obx(() => Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
gFFI.ffiModel.pi.isSet.isTrue &&
|
||||
gFFI.ffiModel.waitForFirstImage.isTrue
|
||||
? emptyOverlay(MyTheme.canvasColor)
|
||||
: () {
|
||||
gFFI.ffiModel.tryShowAndroidActionsOverlay();
|
||||
return Offstage();
|
||||
}(),
|
||||
_bottomWidget(),
|
||||
gFFI.ffiModel.pi.isSet.isFalse
|
||||
? emptyOverlay(MyTheme.canvasColor)
|
||||
: Offstage(),
|
||||
],
|
||||
)),
|
||||
body: Obx(
|
||||
() => getRawPointerAndKeyBody(Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return Container(
|
||||
color: kColorCanvas,
|
||||
child: SafeArea(
|
||||
child: OrientationBuilder(builder: (ctx, orientation) {
|
||||
if (_currentOrientation != orientation) {
|
||||
Timer(const Duration(milliseconds: 200), () {
|
||||
gFFI.dialogManager
|
||||
.resetMobileActionsOverlay(ffi: gFFI);
|
||||
_currentOrientation = orientation;
|
||||
gFFI.canvasModel.updateViewStyle();
|
||||
});
|
||||
}
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: inputModel.isPhysicalMouse.value
|
||||
? getBodyForMobile()
|
||||
: RawTouchGestureDetectorRegion(
|
||||
child: getBodyForMobile(),
|
||||
ffi: gFFI,
|
||||
isCamera: true,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
})
|
||||
],
|
||||
)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getRawPointerAndKeyBody(Widget child) {
|
||||
return CameraRawPointerMouseRegion(
|
||||
inputModel: inputModel,
|
||||
// Disable RawKeyFocusScope before the connecting is established.
|
||||
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
|
||||
child: gFFI.ffiModel.pi.isSet.isTrue
|
||||
? RawKeyFocusScope(
|
||||
focusNode: _physicalFocusNode,
|
||||
inputModel: inputModel,
|
||||
child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBottomAppBar() {
|
||||
return BottomAppBar(
|
||||
elevation: 10,
|
||||
color: MyTheme.accent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
clientClose(sessionId, gFFI.dialogManager);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.tv),
|
||||
onPressed: () {
|
||||
setState(() => _showEdit = false);
|
||||
showOptions(context, widget.id, gFFI.dialogManager);
|
||||
},
|
||||
)
|
||||
] +
|
||||
(isWeb
|
||||
? []
|
||||
: <Widget>[
|
||||
futureBuilder(
|
||||
future: gFFI.invokeMethod(
|
||||
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
|
||||
hasData: (isSupportVoiceCall) => IconButton(
|
||||
color: Colors.white,
|
||||
icon: isAndroid && isSupportVoiceCall
|
||||
? SvgPicture.asset('assets/chat.svg',
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.white, BlendMode.srcIn))
|
||||
: Icon(Icons.message),
|
||||
onPressed: () =>
|
||||
isAndroid && isSupportVoiceCall
|
||||
? showChatOptions(widget.id)
|
||||
: onPressedTextChat(widget.id),
|
||||
))
|
||||
]) +
|
||||
[
|
||||
IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
setState(() => _showEdit = false);
|
||||
showActions(widget.id);
|
||||
},
|
||||
),
|
||||
]),
|
||||
Obx(() => IconButton(
|
||||
color: Colors.white,
|
||||
icon: Icon(Icons.expand_more),
|
||||
onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
|
||||
? null
|
||||
: () {
|
||||
setState(() => _showBar = !_showBar);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget getBodyForMobile() {
|
||||
return Container(
|
||||
color: MyTheme.canvasColor,
|
||||
child: Stack(children: () {
|
||||
final paints = [
|
||||
ImagePaint(),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 10,
|
||||
child: QualityMonitor(gFFI.qualityMonitorModel),
|
||||
),
|
||||
SizedBox(
|
||||
width: 0,
|
||||
height: 0,
|
||||
child: !_showEdit
|
||||
? Container()
|
||||
: TextFormField(
|
||||
textInputAction: TextInputAction.newline,
|
||||
autocorrect: false,
|
||||
// Flutter 3.16.9 Android.
|
||||
// `enableSuggestions` causes secure keyboard to be shown.
|
||||
// https://github.com/flutter/flutter/issues/139143
|
||||
// https://github.com/flutter/flutter/issues/146540
|
||||
// enableSuggestions: false,
|
||||
autofocus: true,
|
||||
focusNode: _mobileFocusNode,
|
||||
maxLines: null,
|
||||
controller: _textController,
|
||||
// trick way to make backspace work always
|
||||
keyboardType: TextInputType.multiline,
|
||||
// `onChanged` may be called depending on the input method if this widget is wrapped in
|
||||
// `Focus(onKeyEvent: ..., child: ...)`
|
||||
// For `Backspace` button in the soft keyboard:
|
||||
// en/fr input method:
|
||||
// 1. The button will not trigger `onKeyEvent` if the text field is not empty.
|
||||
// 2. The button will trigger `onKeyEvent` if the text field is empty.
|
||||
// ko/zh/ja input method: the button will trigger `onKeyEvent`
|
||||
// and the event will not popup if `KeyEventResult.handled` is returned.
|
||||
onChanged: null,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
];
|
||||
return paints;
|
||||
}()));
|
||||
}
|
||||
|
||||
Widget getBodyForDesktopWithListener() {
|
||||
var paints = <Widget>[ImagePaint()];
|
||||
return Container(
|
||||
color: MyTheme.canvasColor, child: Stack(children: paints));
|
||||
}
|
||||
|
||||
List<TTextMenu> _getMobileActionMenus() {
|
||||
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
|
||||
!gFFI.ffiModel.keyboard) {
|
||||
return [];
|
||||
}
|
||||
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
|
||||
if (!enabled) return [];
|
||||
return [
|
||||
TTextMenu(
|
||||
child: Text(translate('Back')),
|
||||
onPressed: () => gFFI.inputModel.onMobileBack(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Home')),
|
||||
onPressed: () => gFFI.inputModel.onMobileHome(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Apps')),
|
||||
onPressed: () => gFFI.inputModel.onMobileApps(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Volume up')),
|
||||
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Volume down')),
|
||||
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
|
||||
),
|
||||
TTextMenu(
|
||||
child: Text(translate('Power')),
|
||||
onPressed: () => gFFI.inputModel.onMobilePower(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void showActions(String id) async {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final x = 120.0;
|
||||
final y = size.height;
|
||||
final mobileActionMenus = _getMobileActionMenus();
|
||||
final menus = toolbarControls(context, id, gFFI);
|
||||
|
||||
final List<PopupMenuEntry<int>> more = [
|
||||
...mobileActionMenus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) =>
|
||||
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
|
||||
.toList(),
|
||||
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
|
||||
...menus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => PopupMenuItem<int>(
|
||||
child: e.value.getChild(),
|
||||
value: e.key + mobileActionMenus.length))
|
||||
.toList(),
|
||||
];
|
||||
() async {
|
||||
var index = await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: more,
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null) {
|
||||
if (index < mobileActionMenus.length) {
|
||||
mobileActionMenus[index].onPressed.call();
|
||||
} else if (index < mobileActionMenus.length + more.length) {
|
||||
menus[index - mobileActionMenus.length].onPressed.call();
|
||||
}
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
onPressedTextChat(String id) {
|
||||
gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
|
||||
gFFI.chatModel.toggleChatOverlay();
|
||||
}
|
||||
|
||||
showChatOptions(String id) async {
|
||||
onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
|
||||
onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
|
||||
|
||||
makeTextMenu(String label, Widget icon, VoidCallback onPressed,
|
||||
{TextStyle? labelStyle}) =>
|
||||
TTextMenu(
|
||||
child: Text(translate(label), style: labelStyle),
|
||||
trailingIcon: Transform.scale(
|
||||
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
|
||||
child: IgnorePointer(
|
||||
child: IconButton(
|
||||
onPressed: null,
|
||||
icon: icon,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
|
||||
final isInVoice = [
|
||||
VoiceCallStatus.waitingForResponse,
|
||||
VoiceCallStatus.connected
|
||||
].contains(gFFI.chatModel.voiceCallStatus.value);
|
||||
final menus = [
|
||||
makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
|
||||
() => onPressedTextChat(widget.id)),
|
||||
isInVoice
|
||||
? makeTextMenu(
|
||||
'End voice call',
|
||||
SvgPicture.asset(
|
||||
'assets/call_wait.svg',
|
||||
colorFilter:
|
||||
ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
|
||||
),
|
||||
onPressEndVoiceCall,
|
||||
labelStyle: TextStyle(color: Colors.redAccent))
|
||||
: makeTextMenu(
|
||||
'Voice call',
|
||||
SvgPicture.asset(
|
||||
'assets/call_wait.svg',
|
||||
colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
|
||||
),
|
||||
onPressVoiceCall),
|
||||
];
|
||||
|
||||
final menuItems = menus
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
|
||||
.toList();
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final x = 120.0;
|
||||
final y = size.height;
|
||||
var index = await showMenu(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(x, y, x, y),
|
||||
items: menuItems,
|
||||
elevation: 8,
|
||||
);
|
||||
if (index != null && index < menus.length) {
|
||||
menus[index].onPressed.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ImagePaint extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = Provider.of<ImageModel>(context);
|
||||
final c = Provider.of<CanvasModel>(context);
|
||||
var s = c.scale;
|
||||
final adjust = c.getAdjustY();
|
||||
return CustomPaint(
|
||||
painter: ImagePainter(
|
||||
image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showOptions(
|
||||
BuildContext context, String id, OverlayDialogManager dialogManager) async {
|
||||
var displays = <Widget>[];
|
||||
final pi = gFFI.ffiModel.pi;
|
||||
final image = gFFI.ffiModel.getConnectionImage();
|
||||
if (image != null) {
|
||||
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
|
||||
}
|
||||
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
|
||||
final cur = pi.currentDisplay;
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < pi.displays.length; ++i) {
|
||||
children.add(InkWell(
|
||||
onTap: () {
|
||||
if (i == cur) return;
|
||||
openMonitorInTheSameTab(i, gFFI, pi);
|
||||
gFFI.dialogManager.dismissAll();
|
||||
},
|
||||
child: Ink(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).hintColor),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: i == cur
|
||||
? Theme.of(context).primaryColor.withOpacity(0.6)
|
||||
: null),
|
||||
child: Center(
|
||||
child: Text((i + 1).toString(),
|
||||
style: TextStyle(
|
||||
color: i == cur ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.bold))))));
|
||||
}
|
||||
displays.add(Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
children: children,
|
||||
)));
|
||||
}
|
||||
if (displays.isNotEmpty) {
|
||||
displays.add(const Divider(color: MyTheme.border));
|
||||
}
|
||||
|
||||
List<TRadioMenu<String>> viewStyleRadios =
|
||||
await toolbarViewStyle(context, id, gFFI);
|
||||
List<TRadioMenu<String>> imageQualityRadios =
|
||||
await toolbarImageQuality(context, id, gFFI);
|
||||
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
|
||||
List<TToggleMenu> displayToggles =
|
||||
await toolbarDisplayToggle(context, id, gFFI);
|
||||
|
||||
dialogManager.show((setState, close, context) {
|
||||
var viewStyle =
|
||||
(viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
|
||||
var imageQuality =
|
||||
(imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
|
||||
.obs;
|
||||
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
|
||||
final radios = [
|
||||
for (var e in viewStyleRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
viewStyle.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) viewStyle.value = v;
|
||||
}
|
||||
: null)),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in imageQualityRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
imageQuality.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) imageQuality.value = v;
|
||||
}
|
||||
: null)),
|
||||
const Divider(color: MyTheme.border),
|
||||
for (var e in codecRadios)
|
||||
Obx(() => getRadio<String>(
|
||||
e.child,
|
||||
e.value,
|
||||
codec.value,
|
||||
e.onChanged != null
|
||||
? (v) {
|
||||
e.onChanged?.call(v);
|
||||
if (v != null) codec.value = v;
|
||||
}
|
||||
: null)),
|
||||
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
|
||||
];
|
||||
|
||||
final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
|
||||
final displayTogglesList = displayToggles
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => Obx(() => CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: rxToggleValues[e.key].value,
|
||||
onChanged: e.value.onChanged != null
|
||||
? (v) {
|
||||
e.value.onChanged?.call(v);
|
||||
if (v != null) rxToggleValues[e.key].value = v;
|
||||
}
|
||||
: null,
|
||||
title: e.value.child)))
|
||||
.toList();
|
||||
final toggles = [
|
||||
...displayTogglesList,
|
||||
];
|
||||
|
||||
var popupDialogMenus = List<Widget>.empty(growable: true);
|
||||
if (popupDialogMenus.isNotEmpty) {
|
||||
popupDialogMenus.add(const Divider(color: MyTheme.border));
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: displays + radios + popupDialogMenus + toggles),
|
||||
);
|
||||
}, clickMaskDismiss: true, backDismiss: true).then((value) {
|
||||
_disableAndroidSoftKeyboard();
|
||||
});
|
||||
}
|
||||
|
||||
class FABLocation extends FloatingActionButtonLocation {
|
||||
FloatingActionButtonLocation location;
|
||||
double offsetX;
|
||||
double offsetY;
|
||||
FABLocation(this.location, this.offsetX, this.offsetY);
|
||||
|
||||
@override
|
||||
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
|
||||
final offset = location.getOffset(scaffoldGeometry);
|
||||
return Offset(offset.dx + offsetX, offset.dy + offsetY);
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,17 @@ class TextureModel {
|
||||
}
|
||||
}
|
||||
|
||||
onViewCameraPageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
ensureControl(int display) {
|
||||
var ctl = _control[display];
|
||||
if (ctl == null) {
|
||||
|
||||
@@ -369,6 +369,7 @@ class InputModel {
|
||||
String? get peerPlatform => parent.target?.ffiModel.pi.platform;
|
||||
bool get isViewOnly => parent.target!.ffiModel.viewOnly;
|
||||
double get devicePixelRatio => parent.target!.canvasModel.devicePixelRatio;
|
||||
bool get isViewCamera => parent.target!.connType == ConnType.viewCamera;
|
||||
|
||||
InputModel(this.parent) {
|
||||
sessionId = parent.target!.sessionId;
|
||||
@@ -471,6 +472,7 @@ class InputModel {
|
||||
|
||||
KeyEventResult handleRawKeyEvent(RawKeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
if (!isInputSourceFlutter) {
|
||||
if (isDesktop) {
|
||||
return KeyEventResult.handled;
|
||||
@@ -525,6 +527,7 @@ class InputModel {
|
||||
|
||||
KeyEventResult handleKeyEvent(KeyEvent e) {
|
||||
if (isViewOnly) return KeyEventResult.handled;
|
||||
if (isViewCamera) return KeyEventResult.handled;
|
||||
if (!isInputSourceFlutter) {
|
||||
if (isDesktop) {
|
||||
return KeyEventResult.handled;
|
||||
@@ -724,6 +727,7 @@ class InputModel {
|
||||
/// [press] indicates a click event(down and up).
|
||||
void inputKey(String name, {bool? down, bool? press}) {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
bind.sessionInputKey(
|
||||
sessionId: sessionId,
|
||||
name: name,
|
||||
@@ -785,6 +789,7 @@ class InputModel {
|
||||
|
||||
/// Send scroll event with scroll distance [y].
|
||||
Future<void> scroll(int y) async {
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json
|
||||
@@ -808,6 +813,7 @@ class InputModel {
|
||||
/// Send mouse press event.
|
||||
Future<void> sendMouse(String type, MouseButtons button) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
await bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: json.encode(modify({'type': type, 'buttons': button.value})));
|
||||
@@ -834,6 +840,7 @@ class InputModel {
|
||||
/// Send mouse movement event with distance in [x] and [y].
|
||||
Future<void> moveMouse(double x, double y) async {
|
||||
if (!keyboardPerm) return;
|
||||
if (isViewCamera) return;
|
||||
var x2 = x.toInt();
|
||||
var y2 = y.toInt();
|
||||
await bind.sessionSendMouse(
|
||||
@@ -857,6 +864,7 @@ class InputModel {
|
||||
_lastScale = 1.0;
|
||||
_stopFling = true;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform == kPeerPlatformAndroid) {
|
||||
handlePointerEvent('touch', kMouseEventTypePanStart, e.position);
|
||||
}
|
||||
@@ -865,6 +873,7 @@ class InputModel {
|
||||
// https://docs.flutter.dev/release/breaking-changes/trackpad-gestures
|
||||
void onPointerPanZoomUpdate(PointerPanZoomUpdateEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform != kPeerPlatformAndroid) {
|
||||
final scale = ((e.scale - _lastScale) * 1000).toInt();
|
||||
_lastScale = e.scale;
|
||||
@@ -904,6 +913,7 @@ class InputModel {
|
||||
handlePointerEvent('touch', kMouseEventTypePanUpdate,
|
||||
Offset(x.toDouble(), y.toDouble()));
|
||||
} else {
|
||||
if (isViewCamera) return;
|
||||
bind.sessionSendMouse(
|
||||
sessionId: sessionId,
|
||||
msg: '{"type": "trackpad", "x": "$x", "y": "$y"}');
|
||||
@@ -912,6 +922,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void _scheduleFling(double x, double y, int delay) {
|
||||
if (isViewCamera) return;
|
||||
if ((x == 0 && y == 0) || _stopFling) {
|
||||
_fling = false;
|
||||
return;
|
||||
@@ -963,6 +974,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
void onPointerPanZoomEnd(PointerPanZoomEndEvent e) {
|
||||
if (isViewCamera) return;
|
||||
if (peerPlatform == kPeerPlatformAndroid) {
|
||||
handlePointerEvent('touch', kMouseEventTypePanEnd, e.position);
|
||||
return;
|
||||
@@ -994,6 +1006,7 @@ class InputModel {
|
||||
_remoteWindowCoords = [];
|
||||
_windowRect = null;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) {
|
||||
if (isPhysicalMouse.value) {
|
||||
isPhysicalMouse.value = false;
|
||||
@@ -1007,6 +1020,7 @@ class InputModel {
|
||||
void onPointUpImage(PointerUpEvent e) {
|
||||
if (isDesktop) _queryOtherWindowCoords = false;
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (isPhysicalMouse.value) {
|
||||
handleMouse(_getMouseEvent(e, _kMouseEventUp), e.position);
|
||||
@@ -1015,6 +1029,7 @@ class InputModel {
|
||||
|
||||
void onPointMoveImage(PointerMoveEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e.kind != ui.PointerDeviceKind.mouse) return;
|
||||
if (_queryOtherWindowCoords) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
@@ -1049,6 +1064,7 @@ class InputModel {
|
||||
|
||||
void onPointerSignalImage(PointerSignalEvent e) {
|
||||
if (isViewOnly) return;
|
||||
if (isViewCamera) return;
|
||||
if (e is PointerScrollEvent) {
|
||||
var dx = e.scrollDelta.dx.toInt();
|
||||
var dy = e.scrollDelta.dy.toInt();
|
||||
@@ -1146,6 +1162,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
final evt = PointerEventToRust(kind, type, evtValue).toJson();
|
||||
if (isViewCamera) return;
|
||||
bind.sessionSendPointer(
|
||||
sessionId: sessionId, msg: json.encode(modify(evt)));
|
||||
}
|
||||
@@ -1177,6 +1194,7 @@ class InputModel {
|
||||
Offset offset, {
|
||||
bool onExit = false,
|
||||
}) {
|
||||
if (isViewCamera) return;
|
||||
double x = offset.dx;
|
||||
double y = max(0.0, offset.dy);
|
||||
if (_checkPeerControlProtected(x, y)) {
|
||||
|
||||
@@ -407,7 +407,9 @@ class FfiModel with ChangeNotifier {
|
||||
parent.target?.fileModel.sendEmptyDirs(evt);
|
||||
}
|
||||
} else if (name == "record_status") {
|
||||
if (desktopType == DesktopType.remote || isMobile) {
|
||||
if (desktopType == DesktopType.remote ||
|
||||
desktopType == DesktopType.viewCamera ||
|
||||
isMobile) {
|
||||
parent.target?.recordingModel.updateStatus(evt['start'] == 'true');
|
||||
}
|
||||
} else {
|
||||
@@ -501,7 +503,9 @@ class FfiModel with ChangeNotifier {
|
||||
final display = int.parse(evt['display']);
|
||||
|
||||
if (_pi.currentDisplay != kAllDisplayValue) {
|
||||
if (bind.peerGetDefaultSessionsCount(id: peerId) > 1) {
|
||||
if (bind.peerGetSessionsCount(
|
||||
id: peerId, connType: parent.target!.connType.index) >
|
||||
1) {
|
||||
if (display != _pi.currentDisplay) {
|
||||
return;
|
||||
}
|
||||
@@ -809,7 +813,9 @@ class FfiModel with ChangeNotifier {
|
||||
_pi.primaryDisplay = currentDisplay;
|
||||
}
|
||||
|
||||
if (bind.peerGetDefaultSessionsCount(id: peerId) <= 1) {
|
||||
if (bind.peerGetSessionsCount(
|
||||
id: peerId, connType: parent.target!.connType.index) <=
|
||||
1) {
|
||||
_pi.currentDisplay = currentDisplay;
|
||||
}
|
||||
|
||||
@@ -827,9 +833,11 @@ class FfiModel with ChangeNotifier {
|
||||
sessionId: sessionId, arg: kOptionTouchMode) !=
|
||||
'';
|
||||
}
|
||||
// FIXME: handle ViewCamera ConnType independently.
|
||||
if (connType == ConnType.fileTransfer) {
|
||||
parent.target?.fileModel.onReady();
|
||||
} else if (connType == ConnType.defaultConn) {
|
||||
} else if (connType == ConnType.defaultConn ||
|
||||
connType == ConnType.viewCamera) {
|
||||
List<Display> newDisplays = [];
|
||||
List<dynamic> displays = json.decode(evt['displays']);
|
||||
for (int i = 0; i < displays.length; ++i) {
|
||||
@@ -859,7 +867,7 @@ class FfiModel with ChangeNotifier {
|
||||
bind.sessionGetToggleOptionSync(
|
||||
sessionId: sessionId, arg: kOptionToggleViewOnly));
|
||||
}
|
||||
if (connType == ConnType.defaultConn) {
|
||||
if (connType == ConnType.defaultConn || connType == ConnType.viewCamera) {
|
||||
final platformAdditions = evt['platform_additions'];
|
||||
if (platformAdditions != null && platformAdditions != '') {
|
||||
try {
|
||||
@@ -2576,7 +2584,8 @@ class ElevationModel with ChangeNotifier {
|
||||
onPortableServiceRunning(bool running) => _running = running;
|
||||
}
|
||||
|
||||
enum ConnType { defaultConn, fileTransfer, portForward, rdp }
|
||||
// The index values of `ConnType` are same as rust protobuf.
|
||||
enum ConnType { defaultConn, fileTransfer, portForward, rdp, viewCamera }
|
||||
|
||||
/// Flutter state manager and data communication with the Rust core.
|
||||
class FFI {
|
||||
@@ -2651,10 +2660,11 @@ class FFI {
|
||||
ffiModel.waitForImageTimer = null;
|
||||
}
|
||||
|
||||
/// Start with the given [id]. Only transfer file if [isFileTransfer], only port forward if [isPortForward].
|
||||
/// Start with the given [id]. Only transfer file if [isFileTransfer], only view camera if [isViewCamera], only port forward if [isPortForward].
|
||||
void start(
|
||||
String id, {
|
||||
bool isFileTransfer = false,
|
||||
bool isViewCamera = false,
|
||||
bool isPortForward = false,
|
||||
bool isRdp = false,
|
||||
String? switchUuid,
|
||||
@@ -2669,9 +2679,15 @@ class FFI {
|
||||
closed = false;
|
||||
auditNote = '';
|
||||
if (isMobile) mobileReset();
|
||||
assert(!(isFileTransfer && isPortForward), 'more than one connect type');
|
||||
assert(
|
||||
(!(isPortForward && isViewCamera)) &&
|
||||
(!(isViewCamera && isPortForward)) &&
|
||||
(!(isPortForward && isFileTransfer)),
|
||||
'more than one connect type');
|
||||
if (isFileTransfer) {
|
||||
connType = ConnType.fileTransfer;
|
||||
} else if (isViewCamera) {
|
||||
connType = ConnType.viewCamera;
|
||||
} else if (isPortForward) {
|
||||
connType = ConnType.portForward;
|
||||
} else {
|
||||
@@ -2691,6 +2707,7 @@ class FFI {
|
||||
sessionId: sessionId,
|
||||
id: id,
|
||||
isFileTransfer: isFileTransfer,
|
||||
isViewCamera: isViewCamera,
|
||||
isPortForward: isPortForward,
|
||||
isRdp: isRdp,
|
||||
switchUuid: switchUuid ?? '',
|
||||
@@ -2706,7 +2723,10 @@ class FFI {
|
||||
return;
|
||||
}
|
||||
final addRes = bind.sessionAddExistedSync(
|
||||
id: id, sessionId: sessionId, displays: Int32List.fromList(displays));
|
||||
id: id,
|
||||
sessionId: sessionId,
|
||||
displays: Int32List.fromList(displays),
|
||||
isViewCamera: isViewCamera);
|
||||
if (addRes != '') {
|
||||
debugPrint(
|
||||
'Unreachable, failed to add existed session to $id, $addRes');
|
||||
@@ -2717,6 +2737,11 @@ class FFI {
|
||||
if (isDesktop && connType == ConnType.defaultConn) {
|
||||
textureModel.updateCurrentDisplay(display ?? 0);
|
||||
}
|
||||
// FIXME: separate cameras displays or shift all indices.
|
||||
if (isDesktop && connType == ConnType.viewCamera) {
|
||||
// FIXME: currently the default 0 is not used.
|
||||
textureModel.updateCurrentDisplay(display ?? 0);
|
||||
}
|
||||
|
||||
// CAUTION: `sessionStart()` and `sessionStartWithDisplays()` are an async functions.
|
||||
// Though the stream is returned immediately, the stream may not be ready.
|
||||
@@ -2993,6 +3018,9 @@ class PeerInfo with ChangeNotifier {
|
||||
bool get isAmyuniIdd =>
|
||||
platformAdditions[kPlatformAdditionsIddImpl] == 'amyuni_idd';
|
||||
|
||||
bool get isSupportViewCamera =>
|
||||
platformAdditions[kPlatformAdditionsSupportViewCamera] == true;
|
||||
|
||||
Display? tryGetDisplay({int? display}) {
|
||||
if (displays.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -791,6 +791,7 @@ class ServerModel with ChangeNotifier {
|
||||
enum ClientType {
|
||||
remote,
|
||||
file,
|
||||
camera,
|
||||
portForward,
|
||||
}
|
||||
|
||||
@@ -798,6 +799,7 @@ class Client {
|
||||
int id = 0; // client connections inner count id
|
||||
bool authorized = false;
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
String portForward = "";
|
||||
String name = "";
|
||||
String peerId = ""; // peer user's id,show at app
|
||||
@@ -815,13 +817,15 @@ class Client {
|
||||
|
||||
RxInt unreadChatMessageCount = 0.obs;
|
||||
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.name, this.peerId,
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.isViewCamera, this.name, this.peerId,
|
||||
this.keyboard, this.clipboard, this.audio);
|
||||
|
||||
Client.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
authorized = json['authorized'];
|
||||
isFileTransfer = json['is_file_transfer'];
|
||||
// TODO: no entry then default.
|
||||
isViewCamera = json['is_view_camera'];
|
||||
portForward = json['port_forward'];
|
||||
name = json['name'];
|
||||
peerId = json['peer_id'];
|
||||
@@ -843,6 +847,7 @@ class Client {
|
||||
data['id'] = id;
|
||||
data['authorized'] = authorized;
|
||||
data['is_file_transfer'] = isFileTransfer;
|
||||
data['is_view_camera'] = isViewCamera;
|
||||
data['port_forward'] = portForward;
|
||||
data['name'] = name;
|
||||
data['peer_id'] = peerId;
|
||||
@@ -863,6 +868,8 @@ class Client {
|
||||
ClientType type_() {
|
||||
if (isFileTransfer) {
|
||||
return ClientType.file;
|
||||
} else if (isViewCamera) {
|
||||
return ClientType.camera;
|
||||
} else if (portForward.isNotEmpty) {
|
||||
return ClientType.portForward;
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,14 @@ import 'package:flutter_hbb/models/input_model.dart';
|
||||
|
||||
/// must keep the order
|
||||
// ignore: constant_identifier_names
|
||||
enum WindowType { Main, RemoteDesktop, FileTransfer, PortForward, Unknown }
|
||||
enum WindowType {
|
||||
Main,
|
||||
RemoteDesktop,
|
||||
FileTransfer,
|
||||
ViewCamera,
|
||||
PortForward,
|
||||
Unknown
|
||||
}
|
||||
|
||||
extension Index on int {
|
||||
WindowType get windowType {
|
||||
@@ -23,6 +30,8 @@ extension Index on int {
|
||||
case 2:
|
||||
return WindowType.FileTransfer;
|
||||
case 3:
|
||||
return WindowType.ViewCamera;
|
||||
case 4:
|
||||
return WindowType.PortForward;
|
||||
default:
|
||||
return WindowType.Unknown;
|
||||
@@ -50,31 +59,46 @@ class RustDeskMultiWindowManager {
|
||||
final List<AsyncCallback> _windowActiveCallbacks = List.empty(growable: true);
|
||||
final List<int> _remoteDesktopWindows = List.empty(growable: true);
|
||||
final List<int> _fileTransferWindows = List.empty(growable: true);
|
||||
final List<int> _viewCameraWindows = List.empty(growable: true);
|
||||
final List<int> _portForwardWindows = List.empty(growable: true);
|
||||
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId) async {
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId,
|
||||
WindowType windowType) async {
|
||||
var params = {
|
||||
'type': WindowType.RemoteDesktop.index,
|
||||
'type': windowType.index,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'session_id': sessionId,
|
||||
};
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
if (windowType == WindowType.RemoteDesktop) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
} else if (windowType == WindowType.ViewCamera) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
peerId,
|
||||
_viewCameraWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This function must be called in the main window thread.
|
||||
// Because the _remoteDesktopWindows is managed in that thread.
|
||||
openMonitorSession(int windowId, String peerId, int display, int displayCount,
|
||||
Rect? screenRect) async {
|
||||
if (_remoteDesktopWindows.length > 1) {
|
||||
for (final windowId in _remoteDesktopWindows) {
|
||||
Rect? screenRect, int windowType) async {
|
||||
final isCamera = windowType == WindowType.ViewCamera.index;
|
||||
final windowIDs = isCamera ? _viewCameraWindows : _remoteDesktopWindows;
|
||||
if (windowIDs.length > 1) {
|
||||
for (final windowId in windowIDs) {
|
||||
if (await DesktopMultiWindow.invokeMethod(
|
||||
windowId,
|
||||
kWindowEventActiveDisplaySession,
|
||||
@@ -91,7 +115,7 @@ class RustDeskMultiWindowManager {
|
||||
? List.generate(displayCount, (index) => index)
|
||||
: [display];
|
||||
var params = {
|
||||
'type': WindowType.RemoteDesktop.index,
|
||||
'type': windowType,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'display': display,
|
||||
@@ -107,10 +131,10 @@ class RustDeskMultiWindowManager {
|
||||
}
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
windowType.windowType,
|
||||
isCamera ? kWindowEventNewViewCamera : kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
windowIDs,
|
||||
jsonEncode(params),
|
||||
screenRect: screenRect,
|
||||
);
|
||||
@@ -277,6 +301,27 @@ class RustDeskMultiWindowManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newViewCamera(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
String? switchUuid,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
remoteId,
|
||||
_viewCameraWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
switchUuid: switchUuid,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newPortForward(
|
||||
String remoteId,
|
||||
bool isRDP, {
|
||||
@@ -324,6 +369,8 @@ class RustDeskMultiWindowManager {
|
||||
return _remoteDesktopWindows;
|
||||
case WindowType.FileTransfer:
|
||||
return _fileTransferWindows;
|
||||
case WindowType.ViewCamera:
|
||||
return _viewCameraWindows;
|
||||
case WindowType.PortForward:
|
||||
return _portForwardWindows;
|
||||
case WindowType.Unknown:
|
||||
@@ -342,6 +389,9 @@ class RustDeskMultiWindowManager {
|
||||
case WindowType.FileTransfer:
|
||||
_fileTransferWindows.clear();
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
_viewCameraWindows.clear();
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
_portForwardWindows.clear();
|
||||
break;
|
||||
|
||||
@@ -60,7 +60,8 @@ class RustdeskImpl {
|
||||
throw UnimplementedError("hostStopSystemKeyPropagate");
|
||||
}
|
||||
|
||||
int peerGetDefaultSessionsCount({required String id, dynamic hint}) {
|
||||
int peerGetSessionsCount(
|
||||
{required String id, required int connType, dynamic hint}) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ class RustdeskImpl {
|
||||
{required String id,
|
||||
required UuidValue sessionId,
|
||||
required Int32List displays,
|
||||
required bool isViewCamera,
|
||||
dynamic hint}) {
|
||||
return '';
|
||||
}
|
||||
@@ -76,6 +78,7 @@ class RustdeskImpl {
|
||||
{required UuidValue sessionId,
|
||||
required String id,
|
||||
required bool isFileTransfer,
|
||||
required bool isViewCamera,
|
||||
required bool isPortForward,
|
||||
required bool isRdp,
|
||||
required String switchUuid,
|
||||
@@ -90,7 +93,8 @@ class RustdeskImpl {
|
||||
'id': id,
|
||||
'password': password,
|
||||
'is_shared_password': isSharedPassword,
|
||||
'isFileTransfer': isFileTransfer
|
||||
'isFileTransfer': isFileTransfer,
|
||||
'isViewCamera': isViewCamera
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
final testClients = [
|
||||
Client(0, false, false, "UserAAAAAA", "123123123", true, false, false),
|
||||
Client(1, false, false, "UserBBBBB", "221123123", true, false, false),
|
||||
Client(2, false, false, "UserC", "331123123", true, false, false),
|
||||
Client(3, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false)
|
||||
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
|
||||
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
|
||||
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
|
||||
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
|
||||
];
|
||||
|
||||
/// flutter run -d {platform} -t test/cm_test.dart to test cm
|
||||
|
||||
Reference in New Issue
Block a user