321 lines
11 KiB
Dart
321 lines
11 KiB
Dart
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)),
|
||
);
|
||
}
|
||
}
|