远程遥控开发中

This commit is contained in:
CC
2026-01-21 13:27:55 +08:00
parent 32b702fb6d
commit 8c39f0daf8
45 changed files with 1994 additions and 245 deletions

View File

@@ -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,
),
),

View File

@@ -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')");
}
}

View File

@@ -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,
),
),
],

View File

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

View File

@@ -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')");
// }
// }

View File

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