mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-05 23:51:04 +03:00
The web client now speaks WebRTC, but the desktop settings page hides the punch options on web and the remote page opens without the session tab that carries the transport name. Let the existing "Enable WebRTC P2P connection" checkbox through on web (the other punch options stay native-only), and add a Transport row to the quality monitor for WebRTC sessions only (with "(TURN)" when ICE relayed), on every platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXJJGEGdgu26wgCppvUXdZ
678 lines
20 KiB
Dart
678 lines
20 KiB
Dart
import 'package:auto_size_text/auto_size_text.dart';
|
|
import 'package:debounce_throttle/debounce_throttle.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hbb/common.dart';
|
|
import 'package:flutter_hbb/models/platform_model.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../consts.dart';
|
|
import '../../desktop/widgets/tabbar_widget.dart';
|
|
import '../../models/chat_model.dart';
|
|
import '../../models/model.dart';
|
|
import 'chat_page.dart';
|
|
|
|
class DraggableChatWindow extends StatelessWidget {
|
|
const DraggableChatWindow(
|
|
{Key? key,
|
|
this.position = Offset.zero,
|
|
required this.width,
|
|
required this.height,
|
|
required this.chatModel})
|
|
: super(key: key);
|
|
|
|
final Offset position;
|
|
final double width;
|
|
final double height;
|
|
final ChatModel chatModel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (draggablePositions.chatWindow.isInvalid()) {
|
|
draggablePositions.chatWindow.update(position);
|
|
}
|
|
return isIOS
|
|
? IOSDraggable(
|
|
position: draggablePositions.chatWindow,
|
|
chatModel: chatModel,
|
|
width: width,
|
|
height: height,
|
|
builder: (context) {
|
|
return Column(
|
|
children: [
|
|
_buildMobileAppBar(context),
|
|
Expanded(
|
|
child: ChatPage(chatModel: chatModel),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
)
|
|
: Draggable(
|
|
checkKeyboard: true,
|
|
checkScreenSize: true,
|
|
position: draggablePositions.chatWindow,
|
|
width: width,
|
|
height: height,
|
|
chatModel: chatModel,
|
|
builder: (context, onPanUpdate) {
|
|
final child = Scaffold(
|
|
resizeToAvoidBottomInset: false,
|
|
appBar: CustomAppBar(
|
|
onPanUpdate: onPanUpdate,
|
|
appBar: (isDesktop || isWebDesktop)
|
|
? _buildDesktopAppBar(context)
|
|
: _buildMobileAppBar(context),
|
|
),
|
|
body: ChatPage(chatModel: chatModel),
|
|
);
|
|
return Container(
|
|
decoration:
|
|
BoxDecoration(border: Border.all(color: MyTheme.border)),
|
|
child: child);
|
|
});
|
|
}
|
|
|
|
Widget _buildMobileAppBar(BuildContext context) {
|
|
return Container(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
height: 50,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 15),
|
|
child: Text(
|
|
translate("Chat"),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontFamily: 'WorkSans',
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 20),
|
|
)),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
onPressed: () {
|
|
chatModel.hideChatWindowOverlay();
|
|
},
|
|
icon: const Icon(
|
|
Icons.keyboard_arrow_down,
|
|
color: Colors.white,
|
|
)),
|
|
IconButton(
|
|
onPressed: () {
|
|
chatModel.hideChatWindowOverlay();
|
|
chatModel.hideChatIconOverlay();
|
|
},
|
|
icon: const Icon(
|
|
Icons.close,
|
|
color: Colors.white,
|
|
))
|
|
],
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDesktopAppBar(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: Theme.of(context).hintColor.withOpacity(0.4)))),
|
|
height: 38,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
|
child: Obx(() => Opacity(
|
|
opacity: chatModel.isWindowFocus.value ? 1.0 : 0.4,
|
|
child: Row(children: [
|
|
Icon(Icons.chat_bubble_outline,
|
|
size: 20, color: Theme.of(context).colorScheme.primary),
|
|
SizedBox(width: 6),
|
|
Text(translate("Chat"))
|
|
])))),
|
|
Padding(
|
|
padding: EdgeInsets.all(2),
|
|
child: ActionIcon(
|
|
message: 'Close',
|
|
icon: IconFont.close,
|
|
onTap: chatModel.hideChatWindowOverlay,
|
|
isClose: true,
|
|
boxSize: 32,
|
|
))
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|
final GestureDragUpdateCallback onPanUpdate;
|
|
final Widget appBar;
|
|
|
|
const CustomAppBar(
|
|
{Key? key, required this.onPanUpdate, required this.appBar})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(onPanUpdate: onPanUpdate, child: appBar);
|
|
}
|
|
|
|
@override
|
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
|
}
|
|
|
|
/// floating buttons of back/home/recent actions for android
|
|
class DraggableMobileActions extends StatelessWidget {
|
|
DraggableMobileActions(
|
|
{this.onBackPressed,
|
|
this.onRecentPressed,
|
|
this.onHomePressed,
|
|
this.onHidePressed,
|
|
required this.position,
|
|
required this.width,
|
|
required this.height,
|
|
required this.scale});
|
|
|
|
final double scale;
|
|
final DraggableKeyPosition position;
|
|
final double width;
|
|
final double height;
|
|
final VoidCallback? onBackPressed;
|
|
final VoidCallback? onHomePressed;
|
|
final VoidCallback? onRecentPressed;
|
|
final VoidCallback? onHidePressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Draggable(
|
|
position: position,
|
|
width: scale * width,
|
|
height: scale * height,
|
|
builder: (_, onPanUpdate) {
|
|
return GestureDetector(
|
|
onPanUpdate: onPanUpdate,
|
|
child: Card(
|
|
color: Colors.transparent,
|
|
shadowColor: Colors.transparent,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: MyTheme.accent.withOpacity(0.4),
|
|
borderRadius:
|
|
BorderRadius.all(Radius.circular(15 * scale))),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
IconButton(
|
|
color: Colors.white,
|
|
onPressed: onBackPressed,
|
|
splashRadius: kDesktopIconButtonSplashRadius,
|
|
icon: const Icon(Icons.arrow_back),
|
|
iconSize: 24 * scale),
|
|
IconButton(
|
|
color: Colors.white,
|
|
onPressed: onHomePressed,
|
|
splashRadius: kDesktopIconButtonSplashRadius,
|
|
icon: const Icon(Icons.home),
|
|
iconSize: 24 * scale),
|
|
IconButton(
|
|
color: Colors.white,
|
|
onPressed: onRecentPressed,
|
|
splashRadius: kDesktopIconButtonSplashRadius,
|
|
icon: const Icon(Icons.more_horiz),
|
|
iconSize: 24 * scale),
|
|
const VerticalDivider(
|
|
width: 0,
|
|
thickness: 2,
|
|
indent: 10,
|
|
endIndent: 10,
|
|
),
|
|
IconButton(
|
|
color: Colors.white,
|
|
onPressed: onHidePressed,
|
|
splashRadius: kDesktopIconButtonSplashRadius,
|
|
icon: const Icon(Icons.keyboard_arrow_down),
|
|
iconSize: 24 * scale),
|
|
],
|
|
),
|
|
)));
|
|
});
|
|
}
|
|
}
|
|
|
|
class DraggableKeyPosition {
|
|
final String key;
|
|
Offset _pos;
|
|
late Debouncer<int> _debouncerStore;
|
|
DraggableKeyPosition(this.key)
|
|
: _pos = DraggablePositions.kInvalidDraggablePosition;
|
|
|
|
get pos => _pos;
|
|
|
|
_loadPosition(String k) {
|
|
final value = bind.getLocalFlutterOption(k: k);
|
|
if (value.isNotEmpty) {
|
|
final parts = value.split(',');
|
|
if (parts.length == 2) {
|
|
return Offset(double.parse(parts[0]), double.parse(parts[1]));
|
|
}
|
|
}
|
|
return DraggablePositions.kInvalidDraggablePosition;
|
|
}
|
|
|
|
load() {
|
|
_pos = _loadPosition(key);
|
|
_debouncerStore = Debouncer<int>(const Duration(milliseconds: 500),
|
|
onChanged: (v) => _store(), initialValue: 0);
|
|
}
|
|
|
|
update(Offset pos) {
|
|
_pos = pos;
|
|
_triggerStore();
|
|
}
|
|
|
|
// Adjust position to keep it in the screen
|
|
// Only used for desktop and web desktop
|
|
tryAdjust(double w, double h, double scale) {
|
|
final size = MediaQuery.of(Get.context!).size;
|
|
w = w * scale;
|
|
h = h * scale;
|
|
double x = _pos.dx;
|
|
double y = _pos.dy;
|
|
if (x + w > size.width) {
|
|
x = size.width - w;
|
|
}
|
|
final tabBarHeight = isDesktop ? kDesktopRemoteTabBarHeight : 0;
|
|
if (y + h > (size.height - tabBarHeight)) {
|
|
y = size.height - tabBarHeight - h;
|
|
}
|
|
if (x < 0) {
|
|
x = 0;
|
|
}
|
|
if (y < 0) {
|
|
y = 0;
|
|
}
|
|
if (x != _pos.dx || y != _pos.dy) {
|
|
update(Offset(x, y));
|
|
}
|
|
}
|
|
|
|
isInvalid() {
|
|
return _pos == DraggablePositions.kInvalidDraggablePosition;
|
|
}
|
|
|
|
_triggerStore() => _debouncerStore.value = _debouncerStore.value + 1;
|
|
_store() {
|
|
bind.setLocalFlutterOption(k: key, v: '${_pos.dx},${_pos.dy}');
|
|
}
|
|
}
|
|
|
|
class DraggablePositions {
|
|
static const kChatWindow = 'draggablePositionChat';
|
|
static const kMobileActions = 'draggablePositionMobile';
|
|
static const kIOSDraggable = 'draggablePositionIOS';
|
|
|
|
static const kInvalidDraggablePosition = Offset(-999999, -999999);
|
|
final chatWindow = DraggableKeyPosition(kChatWindow);
|
|
final mobileActions = DraggableKeyPosition(kMobileActions);
|
|
final iOSDraggable = DraggableKeyPosition(kIOSDraggable);
|
|
|
|
load() {
|
|
chatWindow.load();
|
|
mobileActions.load();
|
|
iOSDraggable.load();
|
|
}
|
|
}
|
|
|
|
DraggablePositions draggablePositions = DraggablePositions();
|
|
|
|
class Draggable extends StatefulWidget {
|
|
Draggable(
|
|
{Key? key,
|
|
this.checkKeyboard = false,
|
|
this.checkScreenSize = false,
|
|
required this.position,
|
|
required this.width,
|
|
required this.height,
|
|
this.chatModel,
|
|
required this.builder})
|
|
: super(key: key);
|
|
|
|
final bool checkKeyboard;
|
|
final bool checkScreenSize;
|
|
final DraggableKeyPosition position;
|
|
final double width;
|
|
final double height;
|
|
final ChatModel? chatModel;
|
|
final Widget Function(BuildContext, GestureDragUpdateCallback) builder;
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _DraggableState(chatModel);
|
|
}
|
|
|
|
class _DraggableState extends State<Draggable> {
|
|
late ChatModel? _chatModel;
|
|
bool _keyboardVisible = false;
|
|
double _saveHeight = 0;
|
|
double _lastBottomHeight = 0;
|
|
|
|
_DraggableState(ChatModel? chatModel) {
|
|
_chatModel = chatModel;
|
|
}
|
|
|
|
get position => widget.position.pos;
|
|
|
|
void onPanUpdate(DragUpdateDetails d) {
|
|
final offset = d.delta;
|
|
final size = MediaQuery.of(context).size;
|
|
double x = 0;
|
|
double y = 0;
|
|
|
|
if (position.dx + offset.dx + widget.width > size.width) {
|
|
x = size.width - widget.width;
|
|
} else if (position.dx + offset.dx < 0) {
|
|
x = 0;
|
|
} else {
|
|
x = position.dx + offset.dx;
|
|
}
|
|
|
|
if (position.dy + offset.dy + widget.height > size.height) {
|
|
y = size.height - widget.height;
|
|
} else if (position.dy + offset.dy < 0) {
|
|
y = 0;
|
|
} else {
|
|
y = position.dy + offset.dy;
|
|
}
|
|
setState(() {
|
|
widget.position.update(Offset(x, y));
|
|
});
|
|
_chatModel?.setChatWindowPosition(position);
|
|
}
|
|
|
|
checkScreenSize() {
|
|
// Ensure the draggable always stays within current screen bounds
|
|
widget.position.tryAdjust(widget.width, widget.height, 1);
|
|
}
|
|
|
|
checkKeyboard() {
|
|
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
final currentVisible = bottomHeight != 0;
|
|
|
|
// save
|
|
if (!_keyboardVisible && currentVisible) {
|
|
_saveHeight = position.dy;
|
|
}
|
|
|
|
// reset
|
|
if (_lastBottomHeight > 0 && bottomHeight == 0) {
|
|
setState(() {
|
|
widget.position.update(Offset(position.dx, _saveHeight));
|
|
});
|
|
}
|
|
|
|
// onKeyboardVisible
|
|
if (_keyboardVisible && currentVisible) {
|
|
final sumHeight = bottomHeight + widget.height;
|
|
final contextHeight = MediaQuery.of(context).size.height;
|
|
if (sumHeight + position.dy > contextHeight) {
|
|
final y = contextHeight - sumHeight;
|
|
setState(() {
|
|
widget.position.update(Offset(position.dx, y));
|
|
});
|
|
}
|
|
}
|
|
|
|
_keyboardVisible = currentVisible;
|
|
_lastBottomHeight = bottomHeight;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.checkKeyboard) {
|
|
checkKeyboard();
|
|
}
|
|
if (widget.checkScreenSize) {
|
|
checkScreenSize();
|
|
}
|
|
return Stack(children: [
|
|
Positioned(
|
|
top: position.dy,
|
|
left: position.dx,
|
|
width: widget.width,
|
|
height: widget.height,
|
|
child: widget.builder(context, onPanUpdate))
|
|
]);
|
|
}
|
|
}
|
|
|
|
class IOSDraggable extends StatefulWidget {
|
|
const IOSDraggable(
|
|
{Key? key,
|
|
this.chatModel,
|
|
required this.position,
|
|
required this.width,
|
|
required this.height,
|
|
required this.builder})
|
|
: super(key: key);
|
|
|
|
final DraggableKeyPosition position;
|
|
final ChatModel? chatModel;
|
|
final double width;
|
|
final double height;
|
|
final Widget Function(BuildContext) builder;
|
|
|
|
@override
|
|
IOSDraggableState createState() =>
|
|
IOSDraggableState(chatModel, width, height);
|
|
}
|
|
|
|
class IOSDraggableState extends State<IOSDraggable> {
|
|
late ChatModel? _chatModel;
|
|
late double _width;
|
|
late double _height;
|
|
bool _keyboardVisible = false;
|
|
double _saveHeight = 0;
|
|
double _lastBottomHeight = 0;
|
|
|
|
IOSDraggableState(ChatModel? chatModel, double w, double h) {
|
|
_chatModel = chatModel;
|
|
_width = w;
|
|
_height = h;
|
|
}
|
|
|
|
DraggableKeyPosition get position => widget.position;
|
|
|
|
checkKeyboard() {
|
|
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
final currentVisible = bottomHeight != 0;
|
|
|
|
// save
|
|
if (!_keyboardVisible && currentVisible) {
|
|
_saveHeight = position.pos.dy;
|
|
}
|
|
|
|
// reset
|
|
if (_lastBottomHeight > 0 && bottomHeight == 0) {
|
|
setState(() {
|
|
position.update(Offset(position.pos.dx, _saveHeight));
|
|
});
|
|
}
|
|
|
|
// onKeyboardVisible
|
|
if (_keyboardVisible && currentVisible) {
|
|
final sumHeight = bottomHeight + _height;
|
|
final contextHeight = MediaQuery.of(context).size.height;
|
|
if (sumHeight + position.pos.dy > contextHeight) {
|
|
final y = contextHeight - sumHeight;
|
|
setState(() {
|
|
position.update(Offset(position.pos.dx, y));
|
|
});
|
|
}
|
|
}
|
|
|
|
_keyboardVisible = currentVisible;
|
|
_lastBottomHeight = bottomHeight;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
position.tryAdjust(_width, _height, 1);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
checkKeyboard();
|
|
return Stack(
|
|
children: [
|
|
Positioned(
|
|
left: position.pos.dx,
|
|
top: position.pos.dy,
|
|
child: GestureDetector(
|
|
onPanUpdate: (details) {
|
|
setState(() {
|
|
position.update(position.pos + details.delta);
|
|
});
|
|
_chatModel?.setChatWindowPosition(position.pos);
|
|
},
|
|
child: Material(
|
|
child: Container(
|
|
width: _width,
|
|
height: _height,
|
|
decoration:
|
|
BoxDecoration(border: Border.all(color: MyTheme.border)),
|
|
child: widget.builder(context),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class QualityMonitor extends StatelessWidget {
|
|
final QualityMonitorModel qualityMonitorModel;
|
|
QualityMonitor(this.qualityMonitorModel);
|
|
|
|
Widget _row(String info, String? value, {Color? rightColor}) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 8,
|
|
child: AutoSizeText(info,
|
|
style: TextStyle(color: Color.fromARGB(255, 210, 210, 210)),
|
|
textAlign: TextAlign.right,
|
|
maxLines: 1)),
|
|
Spacer(flex: 1),
|
|
Expanded(
|
|
flex: 8,
|
|
child: AutoSizeText(value ?? '',
|
|
style: TextStyle(color: rightColor ?? Colors.white),
|
|
maxLines: 1)),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ChangeNotifierProvider.value(
|
|
value: qualityMonitorModel,
|
|
child: Consumer<QualityMonitorModel>(
|
|
builder: (context, qualityMonitorModel, child) => qualityMonitorModel
|
|
.show
|
|
? Container(
|
|
constraints: BoxConstraints(maxWidth: 200),
|
|
padding: const EdgeInsets.all(8),
|
|
color: MyTheme.canvasColor.withAlpha(150),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_row("Speed", qualityMonitorModel.data.speed ?? '-'),
|
|
_row("FPS", qualityMonitorModel.data.fps ?? '-'),
|
|
// let delay be 0 if fps is 0
|
|
_row(
|
|
"Delay",
|
|
"${qualityMonitorModel.data.delay == null ? '-' : (qualityMonitorModel.data.fps ?? "").replaceAll(' ', '').replaceAll('0', '').isEmpty ? 0 : qualityMonitorModel.data.delay}ms",
|
|
rightColor: Colors.green),
|
|
_row("Target Bitrate",
|
|
"${qualityMonitorModel.data.targetBitrate ?? '-'}kb"),
|
|
_row(
|
|
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
|
|
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
|
|
if (qualityMonitorModel.webrtcTransport != null)
|
|
_row("Transport",
|
|
qualityMonitorModel.webrtcTransport!),
|
|
],
|
|
),
|
|
)
|
|
: const SizedBox.shrink()));
|
|
}
|
|
|
|
class BlockableOverlayState extends OverlayKeyState {
|
|
final _middleBlocked = false.obs;
|
|
|
|
VoidCallback? onMiddleBlockedClick; // to-do use listener
|
|
|
|
RxBool get middleBlocked => _middleBlocked;
|
|
|
|
void addMiddleBlockedListener(void Function(bool) cb) {
|
|
_middleBlocked.listen(cb);
|
|
}
|
|
|
|
void setMiddleBlocked(bool blocked) {
|
|
if (blocked != _middleBlocked.value) {
|
|
_middleBlocked.value = blocked;
|
|
}
|
|
}
|
|
|
|
void applyFfi(FFI ffi) {
|
|
ffi.dialogManager.setOverlayState(this);
|
|
ffi.chatModel.setOverlayState(this);
|
|
// make remote page penetrable automatically, effective for chat over remote
|
|
onMiddleBlockedClick = () {
|
|
setMiddleBlocked(false);
|
|
};
|
|
}
|
|
}
|
|
|
|
class BlockableOverlay extends StatelessWidget {
|
|
final Widget underlying;
|
|
final List<OverlayEntry>? upperLayer;
|
|
|
|
final BlockableOverlayState state;
|
|
|
|
BlockableOverlay(
|
|
{required this.underlying, required this.state, this.upperLayer});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final initialEntries = [
|
|
OverlayEntry(builder: (_) => underlying),
|
|
|
|
/// middle layer
|
|
OverlayEntry(
|
|
builder: (context) => Obx(() => Listener(
|
|
onPointerDown: (_) {
|
|
state.onMiddleBlockedClick?.call();
|
|
},
|
|
child: Container(
|
|
color:
|
|
state.middleBlocked.value ? Colors.transparent : null)))),
|
|
];
|
|
|
|
if (upperLayer != null) {
|
|
initialEntries.addAll(upperLayer!);
|
|
}
|
|
|
|
/// set key
|
|
return Overlay(key: state.key, initialEntries: initialEntries);
|
|
}
|
|
}
|