582 lines
23 KiB
Dart
582 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|
import 'desktop_client.dart';
|
|
import 'desktop_input_handler.dart';
|
|
import '../../app/app_controller.dart';
|
|
import '../../widgets/surface_card.dart';
|
|
|
|
class DesktopView extends StatefulWidget {
|
|
const DesktopView({
|
|
super.key,
|
|
required this.controller,
|
|
this.isMaximized = false,
|
|
this.onToggleMaximize,
|
|
});
|
|
|
|
final AppController controller;
|
|
final bool isMaximized;
|
|
final VoidCallback? onToggleMaximize;
|
|
|
|
@override
|
|
State<DesktopView> createState() => _DesktopViewState();
|
|
}
|
|
|
|
class _DesktopViewState extends State<DesktopView> {
|
|
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
|
late DesktopClient _client;
|
|
DesktopInputHandler? _inputHandler;
|
|
|
|
// Settings controllers
|
|
final TextEditingController _displayController = TextEditingController(
|
|
text: ':0.0',
|
|
);
|
|
final TextEditingController _widthController = TextEditingController(
|
|
text: '1280',
|
|
);
|
|
final TextEditingController _heightController = TextEditingController(
|
|
text: '720',
|
|
);
|
|
final TextEditingController _fpsController = TextEditingController(
|
|
text: '30',
|
|
);
|
|
final TextEditingController _bitrateController = TextEditingController(
|
|
text: '2000',
|
|
);
|
|
|
|
bool _useGpu = false;
|
|
bool _adaptiveResolution = true;
|
|
bool _showAdvancedOptions = false;
|
|
String _connectionState = 'disconnected';
|
|
bool _hasStream = false;
|
|
bool _isFocused = false;
|
|
Size _remoteDesktopSize = const Size(1280, 720);
|
|
|
|
final FocusNode _viewportFocusNode = FocusNode();
|
|
final GlobalKey _viewportKey = GlobalKey();
|
|
|
|
StreamSubscription<MediaStream>? _streamSubscription;
|
|
StreamSubscription<String>? _stateSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initRenderer();
|
|
_client = DesktopClient(
|
|
controller: widget.controller,
|
|
sessionId: 'remote-desktop-session',
|
|
);
|
|
_inputHandler = DesktopInputHandler(
|
|
onSendInput: (event) {
|
|
if (_connectionState == 'connected') {
|
|
_client.sendInput(event);
|
|
}
|
|
},
|
|
);
|
|
|
|
_streamSubscription = _client.onRemoteStream.listen((stream) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_localRenderer.srcObject = stream;
|
|
_hasStream = true;
|
|
});
|
|
}
|
|
});
|
|
|
|
_stateSubscription = _client.onConnectionState.listen((state) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_connectionState = state.toLowerCase();
|
|
if (_connectionState == 'disconnected' ||
|
|
_connectionState == 'failed') {
|
|
_hasStream = false;
|
|
_localRenderer.srcObject = null;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _initRenderer() async {
|
|
await _localRenderer.initialize();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_streamSubscription?.cancel();
|
|
_stateSubscription?.cancel();
|
|
_client.disconnect();
|
|
_localRenderer.dispose();
|
|
_displayController.dispose();
|
|
_widthController.dispose();
|
|
_heightController.dispose();
|
|
_fpsController.dispose();
|
|
_bitrateController.dispose();
|
|
_viewportFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _toggleConnection() async {
|
|
if (_connectionState == 'connected' || _connectionState == 'connecting') {
|
|
await _client.disconnect();
|
|
} else {
|
|
final display = _displayController.text.trim();
|
|
int width = int.tryParse(_widthController.text) ?? 1280;
|
|
int height = int.tryParse(_heightController.text) ?? 720;
|
|
|
|
if (_adaptiveResolution) {
|
|
final viewportSize = _getViewportSize();
|
|
if (viewportSize.width > 0 && viewportSize.height > 0) {
|
|
width = (viewportSize.width.toInt() ~/ 2) * 2;
|
|
height = (viewportSize.height.toInt() ~/ 2) * 2;
|
|
_widthController.text = width.toString();
|
|
_heightController.text = height.toString();
|
|
}
|
|
}
|
|
|
|
final fps = int.tryParse(_fpsController.text) ?? 30;
|
|
final bitrate = int.tryParse(_bitrateController.text) ?? 2000;
|
|
_remoteDesktopSize = Size(width.toDouble(), height.toDouble());
|
|
|
|
try {
|
|
await _client.connect(
|
|
display: display,
|
|
width: width,
|
|
height: height,
|
|
fps: fps,
|
|
bitrate: bitrate,
|
|
useGpu: _useGpu,
|
|
);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to connect remote desktop: $e'),
|
|
backgroundColor: Colors.redAccent,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Size _getViewportSize() {
|
|
final renderBox =
|
|
_viewportKey.currentContext?.findRenderObject() as RenderBox?;
|
|
return renderBox?.size ?? Size.zero;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Control panel card
|
|
SurfaceCard(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 16,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
// Connection Button
|
|
ElevatedButton.icon(
|
|
onPressed: _toggleConnection,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _connectionState == 'connected'
|
|
? Colors.redAccent
|
|
: (_connectionState == 'connecting'
|
|
? Colors.orangeAccent
|
|
: theme.colorScheme.primary),
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: 16,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
icon: Icon(
|
|
_connectionState == 'connected'
|
|
? Icons.portable_wifi_off_rounded
|
|
: Icons.settings_remote_rounded,
|
|
),
|
|
label: Text(
|
|
_connectionState == 'connected'
|
|
? '断开连接'
|
|
: (_connectionState == 'connecting'
|
|
? '正在连接...'
|
|
: '连接桌面'),
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
// Status Indicator
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: _connectionState == 'connected'
|
|
? Colors.green.withValues(alpha: 0.15)
|
|
: (_connectionState == 'connecting'
|
|
? Colors.orange.withValues(alpha: 0.15)
|
|
: Colors.grey.withValues(alpha: 0.15)),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: _connectionState == 'connected'
|
|
? Colors.green
|
|
: (_connectionState == 'connecting'
|
|
? Colors.orange
|
|
: Colors.grey),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: _connectionState == 'connected'
|
|
? Colors.green
|
|
: (_connectionState == 'connecting'
|
|
? Colors.orange
|
|
: Colors.grey),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_connectionState == 'connected'
|
|
? '已连接'
|
|
: (_connectionState == 'connecting'
|
|
? '连接中'
|
|
: '未连接'),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: _connectionState == 'connected'
|
|
? Colors.green
|
|
: (_connectionState == 'connecting'
|
|
? Colors.orange
|
|
: (isDark
|
|
? Colors.white70
|
|
: Colors.black87)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Advanced Options Toggle
|
|
TextButton.icon(
|
|
onPressed: () => setState(
|
|
() => _showAdvancedOptions = !_showAdvancedOptions,
|
|
),
|
|
icon: Icon(
|
|
_showAdvancedOptions
|
|
? Icons.expand_less
|
|
: Icons.expand_more,
|
|
),
|
|
label: const Text('高级选项'),
|
|
),
|
|
// Maximize Toggle
|
|
if (widget.onToggleMaximize != null)
|
|
IconButton(
|
|
onPressed: widget.onToggleMaximize,
|
|
icon: Icon(
|
|
widget.isMaximized
|
|
? Icons.fullscreen_exit_rounded
|
|
: Icons.fullscreen_rounded,
|
|
),
|
|
tooltip: widget.isMaximized ? '恢复默认大小' : '最大化',
|
|
),
|
|
],
|
|
),
|
|
if (_showAdvancedOptions) ...[
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 16,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
// Display Selector
|
|
SizedBox(
|
|
width: 100,
|
|
child: TextField(
|
|
controller: _displayController,
|
|
enabled: _connectionState == 'disconnected',
|
|
decoration: const InputDecoration(
|
|
labelText: 'Display',
|
|
prefixIcon: Icon(Icons.monitor_rounded, size: 16),
|
|
),
|
|
),
|
|
),
|
|
// Adaptive Resolution Toggle
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(appText('自适应分辨率', 'Adaptive Resolution')),
|
|
Switch(
|
|
value: _adaptiveResolution,
|
|
onChanged: _connectionState == 'disconnected'
|
|
? (val) => setState(() => _adaptiveResolution = val)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
// Resolution settings
|
|
SizedBox(
|
|
width: 90,
|
|
child: TextField(
|
|
controller: _widthController,
|
|
enabled: _connectionState == 'disconnected' && !_adaptiveResolution,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: '宽度'),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 90,
|
|
child: TextField(
|
|
controller: _heightController,
|
|
enabled: _connectionState == 'disconnected' && !_adaptiveResolution,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: '高度'),
|
|
),
|
|
),
|
|
// FPS / Bitrate
|
|
SizedBox(
|
|
width: 70,
|
|
child: TextField(
|
|
controller: _fpsController,
|
|
enabled: _connectionState == 'disconnected',
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: '帧率'),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 90,
|
|
child: TextField(
|
|
controller: _bitrateController,
|
|
enabled: _connectionState == 'disconnected',
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: '码率 (kbps)',
|
|
),
|
|
),
|
|
),
|
|
// GPU accelerator toggle
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('GPU 加速'),
|
|
Switch(
|
|
value: _useGpu,
|
|
onChanged: _connectionState == 'disconnected'
|
|
? (val) => setState(() => _useGpu = val)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Stream Viewport Card
|
|
Expanded(
|
|
child: Focus(
|
|
focusNode: _viewportFocusNode,
|
|
onFocusChange: (focused) {
|
|
setState(() {
|
|
_isFocused = focused;
|
|
});
|
|
},
|
|
onKeyEvent: (node, event) {
|
|
if (_isFocused &&
|
|
_connectionState == 'connected' &&
|
|
_inputHandler != null) {
|
|
_inputHandler!.handleKeyEvent(event);
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Container(
|
|
key: _viewportKey,
|
|
decoration: BoxDecoration(
|
|
color: isDark
|
|
? Colors.black26
|
|
: Colors.black.withValues(alpha: 0.04),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: _isFocused
|
|
? theme.colorScheme.primary
|
|
: (isDark ? Colors.white10 : Colors.black12),
|
|
width: 2,
|
|
),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Stack(
|
|
children: [
|
|
// Stream Viewport Renderer
|
|
if (_hasStream)
|
|
Positioned.fill(
|
|
child: Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerHover: (event) {
|
|
if (_inputHandler != null) {
|
|
_inputHandler!.handlePointerMove(
|
|
event,
|
|
_getViewportSize(),
|
|
contentSize: _remoteDesktopSize,
|
|
);
|
|
}
|
|
},
|
|
onPointerMove: (event) {
|
|
if (_inputHandler != null) {
|
|
_inputHandler!.handlePointerMove(
|
|
event,
|
|
_getViewportSize(),
|
|
contentSize: _remoteDesktopSize,
|
|
);
|
|
}
|
|
},
|
|
onPointerDown: (event) {
|
|
if (!_viewportFocusNode.hasFocus) {
|
|
_viewportFocusNode.requestFocus();
|
|
}
|
|
if (_inputHandler != null) {
|
|
_inputHandler!.handlePointerDown(
|
|
event,
|
|
_getViewportSize(),
|
|
contentSize: _remoteDesktopSize,
|
|
);
|
|
}
|
|
},
|
|
onPointerUp: (event) {
|
|
if (_inputHandler != null) {
|
|
_inputHandler!.handlePointerUp(
|
|
event,
|
|
_getViewportSize(),
|
|
);
|
|
}
|
|
},
|
|
onPointerSignal: (event) {
|
|
if (event is PointerScrollEvent &&
|
|
_inputHandler != null) {
|
|
_inputHandler!.handleScroll(event);
|
|
}
|
|
},
|
|
child: RTCVideoView(
|
|
_localRenderer,
|
|
objectFit: RTCVideoViewObjectFit
|
|
.RTCVideoViewObjectFitContain,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Placeholder/Status UI overlay
|
|
if (!_hasStream)
|
|
Positioned.fill(
|
|
child: Container(
|
|
color: isDark ? Colors.black54 : Colors.grey[100],
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.monitor_rounded,
|
|
size: 64,
|
|
color: theme.colorScheme.onSurface.withValues(
|
|
alpha: 0.2,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_connectionState == 'connecting'
|
|
? '正在建立 WebRTC 连接,请稍候...'
|
|
: '未开启远程桌面流。点击“连接桌面”启动视频流。',
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onSurface
|
|
.withValues(alpha: 0.6),
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
if (_connectionState == 'connecting') ...[
|
|
const SizedBox(height: 24),
|
|
const CircularProgressIndicator(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Focus watermark badge
|
|
if (_hasStream)
|
|
Positioned(
|
|
right: 8,
|
|
bottom: 8,
|
|
child: AnimatedOpacity(
|
|
opacity: _isFocused ? 0.3 : 0.8,
|
|
duration: const Duration(milliseconds: 200),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_isFocused
|
|
? Icons.keyboard_rounded
|
|
: Icons.keyboard_hide_rounded,
|
|
color: Colors.white,
|
|
size: 14,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_isFocused ? '捕获键盘输入中' : '点击屏幕以捕获键盘',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|