Files
rustdesk/flutter/lib/common/widgets/overlay.dart
rustdesk e677fd510a web: show the WebRTC toggle and transport in the web UI
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
2026-09-04 23:35:33 +08:00

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