远程遥控开发中
This commit is contained in:
@@ -27,14 +27,24 @@ class CenterControlArea extends StatelessWidget {
|
||||
final double emergencyStopButtonWidth = constraints.maxWidth * 0.5;
|
||||
|
||||
return Container(
|
||||
height: 180,
|
||||
// 这里不再使用 Row 包装三个 Expanded,而是直接固定宽度
|
||||
child: Row(
|
||||
//mainAxisSize: MainAxisSize.min, // 极其重要:防止在 spaceBetween 下挤压
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// 左摇杆 (3份)
|
||||
_buildSliderBox(expandedWidth, "底盘", true),
|
||||
// _buildSliderBox(expandedWidth, "底盘", true),
|
||||
SizedBox(
|
||||
width: expandedWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_buildSliderBox(expandedWidth, "底盘", true),
|
||||
SizedBox(height: totalWidth * 0.01),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 急停 (2份)
|
||||
SizedBox(
|
||||
@@ -48,17 +58,21 @@ class CenterControlArea extends StatelessWidget {
|
||||
|
||||
const Text(
|
||||
'双击空白处切换视角',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
color: Colors.grey,
|
||||
),
|
||||
style: TextStyle(fontSize: 8, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 右摇杆 (3份)
|
||||
_buildSliderBox(expandedWidth, "备用", false),
|
||||
// _buildSliderBox(expandedWidth, "备用", false),
|
||||
SizedBox(
|
||||
width: expandedWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [_buildSliderBox(expandedWidth, "备用", false)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -78,9 +92,9 @@ class CenterControlArea extends StatelessWidget {
|
||||
svgStart: "assets/svgs/remote_up.svg",
|
||||
svgCenter: "assets/svgs/remote_layers.svg",
|
||||
svgEnd: "assets/svgs/remote_down.svg",
|
||||
onStart: () => print("$label start"),
|
||||
onCenter: () => print("$label center"),
|
||||
onEnd: () => print("$label end"),
|
||||
onStart: () => debugPrint("$label start"),
|
||||
onCenter: () => debugPrint("$label center"),
|
||||
onEnd: () => debugPrint("$label end"),
|
||||
iconSize: boxWidth * 0.6 * 0.35,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
|
||||
|
||||
class MyVideoPlayer extends StatefulWidget {
|
||||
final String deviceIp;
|
||||
final DeviceEntity device;
|
||||
final UserEntity user;
|
||||
|
||||
const MyVideoPlayer({
|
||||
Key? key,
|
||||
required this.deviceIp,
|
||||
required this.device,
|
||||
required this.user,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MyVideoPlayer> createState() => _MyVideoPlayerState();
|
||||
}
|
||||
|
||||
class _MyVideoPlayerState extends State<MyVideoPlayer> {
|
||||
InAppLocalhostServer? _localServer;
|
||||
InAppWebViewController? _webViewController;
|
||||
bool _isServerRunning = false;
|
||||
int? actualPort;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startServer();
|
||||
}
|
||||
|
||||
Future<void> _startServer() async {
|
||||
int availablePort = 0;
|
||||
try {
|
||||
var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
availablePort = socket.port;
|
||||
await socket.close();
|
||||
} catch (e) {
|
||||
availablePort = 8080;
|
||||
}
|
||||
|
||||
_localServer = InAppLocalhostServer(port: availablePort);
|
||||
await _localServer!.start();
|
||||
actualPort = availablePort;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isServerRunning = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localServer?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 防止父级因其他状态 setState 导致视频闪烁
|
||||
if (!_isServerRunning) {
|
||||
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
}
|
||||
|
||||
// [性能关键] RepaintBoundary 必须包裹渲染密集型的 WebView
|
||||
return RepaintBoundary(
|
||||
child: InAppWebView(
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri("http://localhost:$actualPort/assets/www/webrtc/playwebrtc.html"),
|
||||
),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
preferredContentMode: UserPreferredContentMode.MOBILE,
|
||||
// 优化性能:禁用滚动,开启硬件加速
|
||||
supportZoom: false,
|
||||
disableHorizontalScroll: true,
|
||||
disableVerticalScroll: true,
|
||||
hardwareAcceleration: true,
|
||||
),
|
||||
onWebViewCreated: (controller) => _webViewController = controller,
|
||||
onPermissionRequest: (controller, request) async {
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onLoadStop: (controller, url) {
|
||||
_initWebRTC(widget.deviceIp, widget.device.deviceName, widget.user.token);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initWebRTC(String deviceIp, String deviceId, String token) {
|
||||
final streamUrl = "webrtc://$deviceIp/live/livestream/$deviceId?token=$token";
|
||||
_webViewController?.evaluateJavascript(source: "setStreamUrl('$streamUrl')");
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class StatusChip extends StatefulWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final bool breathing; // 是否开启呼吸灯特效
|
||||
final IconData? icon;
|
||||
final String? svgPath;
|
||||
final Color? svgColor;
|
||||
final bool breathing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const StatusChip({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
this.icon,
|
||||
this.svgPath,
|
||||
this.svgColor,
|
||||
this.breathing = false,
|
||||
this.onTap,
|
||||
});
|
||||
}) : assert(icon != null || svgPath != null, '必须提供 icon 或 svgPath 其中之一');
|
||||
|
||||
@override
|
||||
State<StatusChip> createState() => _StatusChipState();
|
||||
@@ -34,10 +39,19 @@ class _StatusChipState extends State<StatusChip>
|
||||
);
|
||||
if (widget.breathing) _controller.repeat(reverse: true);
|
||||
|
||||
// 动画区间从 0.3 到 1.0
|
||||
_opacityAnimation = Tween<double>(
|
||||
begin: 0.4,
|
||||
begin: 0.3,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.linear));
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StatusChip oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.breathing != oldWidget.breathing) {
|
||||
widget.breathing ? _controller.repeat(reverse: true) : _controller.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -48,33 +62,56 @@ class _StatusChipState extends State<StatusChip>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 【关键】获取颜色传入时的透明度(比如你传了 0.6,baseOpacity 就是 0.6)
|
||||
final double baseOpacity = widget.color.opacity;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _opacityAnimation,
|
||||
builder: (context, child) {
|
||||
final currentAlpha = widget.breathing ? _opacityAnimation.value : 0.6;
|
||||
// 【关键】如果不呼吸,直接用 baseOpacity,不再强制给 1.0
|
||||
final currentAlpha = widget.breathing
|
||||
? _opacityAnimation.value * baseOpacity
|
||||
: baseOpacity;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
// 这里应用计算后的透明度
|
||||
color: widget.color.withOpacity(currentAlpha),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.color.withOpacity(0.35),
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, color: Colors.white, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
if (widget.svgPath != null)
|
||||
SvgPicture.asset(
|
||||
widget.svgPath!,
|
||||
width: 14,
|
||||
height: 14,
|
||||
colorFilter: ColorFilter.mode(
|
||||
widget.svgColor ?? Colors.white,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: widget.svgColor ?? Colors.white,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
widget.text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/status_chip.dart';
|
||||
|
||||
import '../../../devices/presentation/bloc/devices_cubit.dart';
|
||||
@@ -23,7 +25,7 @@ class TopStatusBar extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
// 1. 返回按钮 (对应 SimpleSmallFunctionButton)
|
||||
_buildIconButton(Icons.arrow_back_ios_new, () => context.pop()),
|
||||
_buildIconButton("assets/svgs/remote_back.svg", () => context.pop()),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 2. 控制状态 (对应 StatusChipLeft)
|
||||
@@ -45,38 +47,47 @@ class TopStatusBar extends StatelessWidget {
|
||||
|
||||
// 3. 锁定状态 (对应 SmallFunctionButton)
|
||||
_buildIconButton(
|
||||
remoteState.isLocked ? Icons.lock : Icons.lock_open,
|
||||
remoteState.isLocked
|
||||
? "assets/svgs/remote_lock.svg"
|
||||
: "assets/svgs/remote_lock_open.svg",
|
||||
() => context.read<RemoteControlCubit>().toggleLock(),
|
||||
isSelected: remoteState.isLocked,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 4. 刷新按钮
|
||||
_buildIconButton(Icons.refresh, () {
|
||||
// 刷新 WebView 逻辑
|
||||
}),
|
||||
_buildIconButton("assets/svgs/remote_refresh.svg", () {}),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// TODO
|
||||
_buildControlModeChip(,remoteState.runningStatusModel.controlMode),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
_buildVoltageChip(remoteState.voltage),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 5. 信号延迟 (对应 pingStatusChip)
|
||||
_buildPingChip(remoteState.ping),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 6. 电量 (对应 StatusChipRight)
|
||||
_buildBatteryChip(remoteState?.battery ?? 0),
|
||||
_buildBatteryChip(remoteState.battery),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 对应 Android 里的 SimpleSmallFunctionButton 样式
|
||||
Widget _buildIconButton(
|
||||
IconData icon,
|
||||
String svgAsset, // 改为 String 路径,例如 'assets/icons/stop.svg'
|
||||
VoidCallback onTap, {
|
||||
bool isSelected = false,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -87,7 +98,12 @@ class TopStatusBar extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.3), width: 0.5),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 18),
|
||||
padding: const EdgeInsets.all(7),
|
||||
child: SvgPicture.asset(
|
||||
svgAsset,
|
||||
// 这里可以使用 colorFilter 来改变 SVG 的颜色(如果需要根据选中状态变色)
|
||||
colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -98,18 +114,81 @@ class TopStatusBar extends StatelessWidget {
|
||||
: (ping < 200 ? Colors.orange : Colors.red);
|
||||
return StatusChip(
|
||||
text: "$ping ms",
|
||||
color: color,
|
||||
color: color.withValues(alpha: 0.6),
|
||||
svgColor: color,
|
||||
icon: Icons.network_check,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlModeChip(ControlMode mode) {
|
||||
String displayText;
|
||||
switch (mode) {
|
||||
case ControlMode.BLE:
|
||||
displayText = '蓝牙模式';
|
||||
break;
|
||||
case ControlMode.LOCAL:
|
||||
displayText = '本地模式';
|
||||
break;
|
||||
case ControlMode.TCP:
|
||||
displayText = 'TCP模式';
|
||||
break;
|
||||
case ControlMode.NONE:
|
||||
displayText = '无模式';
|
||||
break;
|
||||
case ControlMode.OTHER:
|
||||
displayText = '其他模式';
|
||||
break;
|
||||
default:
|
||||
displayText = '未知模式';
|
||||
}
|
||||
|
||||
return StatusChip(
|
||||
text: displayText,
|
||||
color: Colors.blue.withValues(alpha: 0.6),
|
||||
svgColor: Colors.blue,
|
||||
svgPath: "assets/svgs/remote_net.svg",
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVoltageChip(int voltage) {
|
||||
return StatusChip(
|
||||
text: "$voltage V",
|
||||
color: Color(0xFFFACC15).withValues(alpha: 0.6),
|
||||
svgColor: Color(0xFFFACC15),
|
||||
svgPath: "assets/svgs/remote_zap.svg",
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryChip(int level) {
|
||||
IconData icon = level > 80
|
||||
? Icons.battery_full
|
||||
: (level > 20 ? Icons.battery_3_bar : Icons.battery_alert);
|
||||
Color color = level > 80
|
||||
? Colors.green
|
||||
: (level > 30 ? Colors.orange : Colors.red);
|
||||
return StatusChip(text: "$level%", color: color, icon: icon);
|
||||
// 1. 根据数值判断图片路径 (逻辑与之前一致)
|
||||
String svgPath;
|
||||
Color color;
|
||||
|
||||
if (level >= 90) {
|
||||
svgPath = "assets/svgs/remote_battery_full.svg";
|
||||
color = Colors.green;
|
||||
} else if (level >= 50) {
|
||||
svgPath = "assets/svgs/remote_battery_middle.svg";
|
||||
color = Colors.greenAccent;
|
||||
} else if (level >= 20) {
|
||||
svgPath = "assets/svgs/remote_battery_low.svg";
|
||||
color = Colors.orange;
|
||||
} else if (level >= 10) {
|
||||
svgPath = "assets/svgs/remote_battery_warning.svg";
|
||||
color = Colors.redAccent;
|
||||
} else {
|
||||
svgPath = "assets/svgs/remote_battery_none.svg";
|
||||
color = Colors.red;
|
||||
}
|
||||
|
||||
// 2. 使用 StatusChip 包装,但 icon 传入 SvgPicture
|
||||
return StatusChip(
|
||||
text: "$level%",
|
||||
color: color.withValues(alpha: 0.6),
|
||||
svgColor: color,
|
||||
// 如果你的 StatusChip 已经改为接受 Widget,则直接传 SvgPicture
|
||||
// 如果 StatusChip 目前只接受 IconData,你需要按我之前的建议给它加个 leading 参数
|
||||
svgPath: svgPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// import 'dart:io';
|
||||
// import 'package:flutter/foundation.dart';
|
||||
// import 'package:flutter/material.dart';
|
||||
// import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
// import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
// import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
|
||||
//
|
||||
// class MyVideoPlayer extends StatefulWidget {
|
||||
// final String deviceIp;
|
||||
// final DeviceEntity device;
|
||||
// final UserEntity user;
|
||||
//
|
||||
// const MyVideoPlayer({
|
||||
// Key? key,
|
||||
// required this.deviceIp,
|
||||
// required this.device,
|
||||
// required this.user,
|
||||
// }) : super(key: key);
|
||||
//
|
||||
// @override
|
||||
// State<MyVideoPlayer> createState() => _MyVideoPlayerState();
|
||||
// }
|
||||
//
|
||||
// class _MyVideoPlayerState extends State<MyVideoPlayer> {
|
||||
// InAppLocalhostServer? _localServer;
|
||||
// InAppWebViewController? _webViewController;
|
||||
// bool _isServerRunning = false;
|
||||
// int? actualPort;
|
||||
//
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// _startServer();
|
||||
// }
|
||||
//
|
||||
// Future<void> _startServer() async {
|
||||
// int availablePort = 0;
|
||||
// try {
|
||||
// var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
// availablePort = socket.port;
|
||||
// await socket.close();
|
||||
// } catch (e) {
|
||||
// availablePort = 8080;
|
||||
// }
|
||||
//
|
||||
// _localServer = InAppLocalhostServer(port: availablePort);
|
||||
// await _localServer!.start();
|
||||
// actualPort = availablePort;
|
||||
//
|
||||
// if (mounted) {
|
||||
// setState(() {
|
||||
// _isServerRunning = true;
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// void dispose() {
|
||||
// _localServer?.close();
|
||||
// super.dispose();
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// // 防止父级因其他状态 setState 导致视频闪烁
|
||||
// if (!_isServerRunning) {
|
||||
// return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
// }
|
||||
//
|
||||
// // [性能关键] RepaintBoundary 必须包裹渲染密集型的 WebView
|
||||
// return RepaintBoundary(
|
||||
// child: InAppWebView(
|
||||
// initialUrlRequest: URLRequest(
|
||||
// url: WebUri("http://localhost:$actualPort/assets/www/webrtc/playwebrtc.html"),
|
||||
// ),
|
||||
// initialSettings: InAppWebViewSettings(
|
||||
// // 1. 基础播放设置
|
||||
// javaScriptEnabled: true,
|
||||
// mediaPlaybackRequiresUserGesture: false,
|
||||
// allowsInlineMediaPlayback: true,
|
||||
// preferredContentMode: UserPreferredContentMode.MOBILE,
|
||||
//
|
||||
// // 2. 性能与重绘优化
|
||||
// supportZoom: false,
|
||||
// disableHorizontalScroll: true,
|
||||
// disableVerticalScroll: true,
|
||||
// hardwareAcceleration: true, // 开启硬件加速
|
||||
// offscreenPreRaster: false, // 禁用离屏预渲染以节省内存
|
||||
// useHybridComposition: true,
|
||||
// // 3. 内存与日志优化 (6.x 正确配置)
|
||||
// isInspectable: false, // 生产环境关闭 Web 检查器
|
||||
// cacheMode: CacheMode.LOAD_NO_CACHE, // 禁用缓存,WebRTC 不需要缓存
|
||||
// useShouldInterceptRequest: false, // 减少 Dart 与 Native 桥接通信
|
||||
// ),
|
||||
// onWebViewCreated: (controller) => _webViewController = controller,
|
||||
// onPermissionRequest: (controller, request) async {
|
||||
// return PermissionResponse(
|
||||
// resources: request.resources,
|
||||
// action: PermissionResponseAction.GRANT,
|
||||
// );
|
||||
// },
|
||||
// onLoadStop: (controller, url) {
|
||||
// _initWebRTC(widget.deviceIp, widget.device.deviceName, widget.user.token);
|
||||
// },
|
||||
// // --- 彻底屏蔽 Console 日志,防止垃圾产生 ---
|
||||
// onConsoleMessage: (controller, consoleMessage) {
|
||||
// // 这里什么都不写,直接拦截并消耗掉 JS 层的日志
|
||||
// // 防止海量 WebRTC 日志传回 Flutter 触发 GC
|
||||
// },
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// void _initWebRTC(String deviceIp, String deviceId, String token) {
|
||||
// final streamUrl = "webrtc://$deviceIp/live/livestream/$deviceId?token=$token";
|
||||
// _webViewController?.evaluateJavascript(source: "setStreamUrl('$streamUrl')");
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,331 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class WebRTCLocalPlayer extends StatefulWidget {
|
||||
final String streamUrl;
|
||||
final bool showLeftPip;
|
||||
final bool showRightPip;
|
||||
|
||||
const WebRTCLocalPlayer({
|
||||
super.key,
|
||||
required this.streamUrl,
|
||||
this.showLeftPip = true,
|
||||
this.showRightPip = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WebRTCLocalPlayer> createState() => _WebRTCLocalPlayerState();
|
||||
}
|
||||
|
||||
class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
final RTCVideoRenderer _renderer = RTCVideoRenderer();
|
||||
RTCPeerConnection? _peerConnection;
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool _isFrontMain = true;
|
||||
|
||||
// 使用 ValueNotifier 配合局部刷新,提升拖拽性能
|
||||
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(
|
||||
const Offset(20, 80),
|
||||
);
|
||||
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(
|
||||
const Offset(200, 80),
|
||||
);
|
||||
bool _isPosInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prepareAndConnect();
|
||||
}
|
||||
|
||||
Future<void> _prepareAndConnect() async {
|
||||
try {
|
||||
await _renderer.initialize();
|
||||
if (!mounted) return;
|
||||
await _initWebRTCConnection();
|
||||
setState(() => _isInitialized = true);
|
||||
} catch (e) {
|
||||
debugPrint("初始化失败: $e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initWebRTCConnection() async {
|
||||
_peerConnection = await createPeerConnection({
|
||||
"sdpSemantics": "unified-plan",
|
||||
"iceServers": [
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
],
|
||||
});
|
||||
|
||||
await _peerConnection!.addTransceiver(
|
||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
||||
);
|
||||
|
||||
_peerConnection!.onTrack = (RTCTrackEvent event) {
|
||||
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
|
||||
if (mounted) setState(() => _renderer.srcObject = event.streams[0]);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer({
|
||||
'offerToReceiveVideo': 1,
|
||||
});
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
Uri uri = Uri.parse(
|
||||
widget.streamUrl.replaceFirst('webrtc://', 'http://'),
|
||||
);
|
||||
String apiUrl = "http://${uri.host}:1985/rtc/v1/play/";
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(apiUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
"api": apiUrl,
|
||||
"streamurl": widget.streamUrl,
|
||||
"clientip": null,
|
||||
"sdp": offer.sdp,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(data['sdp'], 'answer'),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("信令错误: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_peerConnection?.dispose();
|
||||
_renderer.srcObject = null;
|
||||
_renderer.dispose();
|
||||
_leftPosNotifier.dispose();
|
||||
_rightPosNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 四分屏裁剪视图
|
||||
Widget _buildQuadrantView({required Alignment alignment}) {
|
||||
if (!_isInitialized || _renderer.srcObject == null)
|
||||
return Container(color: Colors.black);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: ClipRect(
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: 2.0,
|
||||
heightFactor: 2.0,
|
||||
alignment: alignment,
|
||||
child: RTCVideoView(
|
||||
_renderer,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
mirror: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 主视角的矩形边缘羽化效果
|
||||
Widget _buildMainViewWithFeathering() {
|
||||
return ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.white,
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [0.0, 0.15, 0.85, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.white,
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [0.0, 0.05, 0.95, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: _buildQuadrantView(
|
||||
alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized)
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double pipW = constraints.maxWidth / 5;
|
||||
final double pipH = pipW * 9 / 16;
|
||||
|
||||
// 核心:处理 Windows 窗口大小变化时的坐标修正
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
_correctPosition(_leftPosNotifier, constraints, pipW, pipH);
|
||||
_correctPosition(_rightPosNotifier, constraints, pipW, pipH);
|
||||
});
|
||||
|
||||
if (!_isPosInitialized) {
|
||||
_leftPosNotifier.value = const Offset(20, 80);
|
||||
_rightPosNotifier.value = Offset(
|
||||
constraints.maxWidth - pipW - 20,
|
||||
80,
|
||||
);
|
||||
_isPosInitialized = true;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// ① 背景虚化层(背景两侧模糊,模拟视觉聚焦)
|
||||
Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildQuadrantView(
|
||||
alignment: _isFrontMain
|
||||
? Alignment.topLeft
|
||||
: Alignment.topRight,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
|
||||
child: Container(color: Colors.black.withOpacity(0.55)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ② 主视频层
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onDoubleTap: () =>
|
||||
setState(() => _isFrontMain = !_isFrontMain),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: _buildMainViewWithFeathering(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ③ 悬浮小窗 - 解决阻尼感的 1:1 稳定拖拽
|
||||
if (widget.showLeftPip)
|
||||
_buildFastPip(
|
||||
_leftPosNotifier,
|
||||
Alignment.bottomLeft,
|
||||
constraints,
|
||||
pipW,
|
||||
pipH,
|
||||
),
|
||||
if (widget.showRightPip)
|
||||
_buildFastPip(
|
||||
_rightPosNotifier,
|
||||
Alignment.bottomRight,
|
||||
constraints,
|
||||
pipW,
|
||||
pipH,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _correctPosition(
|
||||
ValueNotifier<Offset> notifier,
|
||||
BoxConstraints constraints,
|
||||
double w,
|
||||
double h,
|
||||
) {
|
||||
double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity);
|
||||
double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity);
|
||||
double newX = notifier.value.dx.clamp(0.0, maxX);
|
||||
double newY = notifier.value.dy.clamp(0.0, maxY);
|
||||
if (notifier.value.dx != newX || notifier.value.dy != newY) {
|
||||
notifier.value = Offset(newX, newY);
|
||||
}
|
||||
}
|
||||
|
||||
// 极致丝滑的局部刷新组件
|
||||
Widget _buildFastPip(
|
||||
ValueNotifier<Offset> notifier,
|
||||
Alignment align,
|
||||
BoxConstraints constraints,
|
||||
double w,
|
||||
double h,
|
||||
) {
|
||||
return ValueListenableBuilder<Offset>(
|
||||
valueListenable: notifier,
|
||||
builder: (context, pos, child) {
|
||||
return Positioned(
|
||||
left: pos.dx,
|
||||
top: pos.dy,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanUpdate: (details) {
|
||||
// 关键修正:使用 notifier.value.dx 替换 pos.dx,消除位移丢失导致的阻尼感
|
||||
final double nextX = (notifier.value.dx + details.delta.dx).clamp(
|
||||
0.0,
|
||||
(constraints.maxWidth - w).clamp(0.0, double.infinity),
|
||||
);
|
||||
final double nextY = (notifier.value.dy + details.delta.dy).clamp(
|
||||
0.0,
|
||||
(constraints.maxHeight - h).clamp(0.0, double.infinity),
|
||||
);
|
||||
notifier.value = Offset(nextX, nextY);
|
||||
},
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildQuadrantView(alignment: align),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user