Files
flutterApp/lib/features/home/presentation/widgets/video.dart
2026-03-09 12:41:55 +08:00

321 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
// 定义视图枚举(前、右、后、左)
enum VideoViewType {
front, // 前视图(左上)
right, // 右视图(右上)
back, // 后视图(右下)
left, // 左视图(左下)
}
class WebRTCMapPlayer extends StatefulWidget {
final String streamUrl;
final bool showLeftPip;
final bool showRightPip;
final double width; // 弹窗宽度
final double height; // 弹窗高度
final Function() onClose; // 关闭回调
const WebRTCMapPlayer({
super.key,
required this.streamUrl,
required this.onClose,
this.showLeftPip = false,
this.showRightPip = false,
this.width = 320, // 默认宽度
this.height = 180, // 默认高度(16:9比例)
});
@override
State<WebRTCMapPlayer> createState() => _WebRTCMapPlayerState();
}
class _WebRTCMapPlayerState extends State<WebRTCMapPlayer> {
final RTCVideoRenderer _renderer = RTCVideoRenderer();
RTCPeerConnection? _peerConnection;
bool _isInitialized = false;
VideoViewType _currentViewType = VideoViewType.front; // 初始为前视图
// 悬浮小窗位置(小弹窗内的悬浮窗,按比例缩小)
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(10, 40));
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(60, 40));
bool _isPosInitialized = false;
@override
void initState() {
super.initState();
_prepareAndConnect();
}
// 初始化并连接 WebRTC
Future<void> _prepareAndConnect() async {
try {
await _renderer.initialize();
if (!mounted) return;
await _initWebRTCConnection();
setState(() => _isInitialized = true);
} catch (e) {
debugPrint("视频初始化失败: $e");
}
}
// 初始化 WebRTC 连接
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 {
final offer = await _peerConnection!.createOffer({'offerToReceiveVideo': 1});
await _peerConnection!.setLocalDescription(offer);
final uri = Uri.parse(widget.streamUrl.replaceFirst('webrtc://', 'http://'));
final 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("WebRTC 信令错误: $e");
}
}
// 双击切换视图(前→右→后→左→前 循环)
void _switchViewType() {
setState(() {
switch (_currentViewType) {
case VideoViewType.front:
_currentViewType = VideoViewType.right;
break;
case VideoViewType.right:
_currentViewType = VideoViewType.back;
break;
case VideoViewType.back:
_currentViewType = VideoViewType.left;
break;
case VideoViewType.left:
_currentViewType = VideoViewType.front;
break;
}
});
}
// 根据视图类型获取对齐方式
Alignment _getViewAlignment() {
switch (_currentViewType) {
case VideoViewType.front:
return Alignment.topLeft;
case VideoViewType.right:
return Alignment.topRight;
case VideoViewType.back:
return Alignment.bottomRight;
case VideoViewType.left:
return Alignment.bottomLeft;
}
}
// 获取视图名称
String _getViewName() {
switch (_currentViewType) {
case VideoViewType.front:
return "前视角";
case VideoViewType.right:
return "右视角";
case VideoViewType.back:
return "后视角";
case VideoViewType.left:
return "左视角";
}
}
// 四分屏裁剪视图
Widget _buildQuadrantView({required Alignment alignment}) {
if (!_isInitialized || _renderer.srcObject == null) {
return Container(
color: Colors.black,
child: const Center(
child: Text("加载中...", style: TextStyle(color: Colors.white)),
),
);
}
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) => 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) => 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: _getViewAlignment()),
),
);
}
// 修正悬浮窗位置
void _correctPosition(ValueNotifier<Offset> notifier, double w, double h) {
final maxX = (widget.width - w).clamp(0.0, double.infinity);
final maxY = (widget.height - h).clamp(0.0, double.infinity);
final newX = notifier.value.dx.clamp(0.0, maxX);
final 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) {
final pipW = widget.width / 4;
final pipH = pipW * 9 / 16;
return ValueListenableBuilder<Offset>(
valueListenable: notifier,
builder: (context, pos, child) {
_correctPosition(notifier, pipW, pipH);
return Positioned(left: pos.dx, top: pos.dy, child: child!);
},
child: GestureDetector(
onPanUpdate: (details) {
notifier.value = Offset(notifier.value.dx + details.delta.dx, notifier.value.dy + details.delta.dy);
},
child: Container(
width: pipW,
height: pipH,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 8, offset: const Offset(0, 2))],
),
clipBehavior: Clip.antiAlias,
child: _buildQuadrantView(alignment: align),
),
),
);
}
@override
void dispose() {
// 彻底销毁 WebRTC 资源
_peerConnection?.close();
_peerConnection?.dispose();
_renderer.srcObject = null;
_renderer.dispose();
_leftPosNotifier.dispose();
_rightPosNotifier.dispose();
debugPrint("视频组件已销毁");
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 2, offset: const Offset(0, 1))],
),
child: _isInitialized
? Stack(
children: [
// 主视频层(双击切换视图)
GestureDetector(
onDoubleTap: _switchViewType,
child: SizedBox.expand(child: _buildMainViewWithFeathering()),
),
// 视图标识(左上角 - 圆角透明背景)
Positioned(
top: 10,
left: 10,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
//color: Colors.black.withOpacity(0.5), // 半透明黑色背景
borderRadius: BorderRadius.circular(8), // 圆角效果
//border: Border.all(color: Colors.white.withOpacity(0.3), width: 0.5), // 可选:加一层浅边框
),
child: Text(
_getViewName(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: [Shadow(color: Colors.black, blurRadius: 2)], // 可选:文字加阴影更清晰
),
),
),
),
// 关闭按钮(右上角,修正位置)
Positioned(
top: 10,
right: 10,
child: GestureDetector(
onTap: widget.onClose,
child: Container(
width: 24,
height: 24,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black, blurRadius: 2)],
),
child: const Icon(Icons.close, color: Colors.white, size: 16),
),
),
),
// 悬浮小窗(小弹窗内的迷你窗)
if (widget.showLeftPip) _buildFastPip(_leftPosNotifier, Alignment.bottomLeft),
if (widget.showRightPip) _buildFastPip(_rightPosNotifier, Alignment.bottomRight),
],
)
: const Center(child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)),
);
}
}