2025-05-07 15:07:36 +08:00

1079 lines
37 KiB
Dart
Raw Permalink 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:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_decode_plugin/video_decode_plugin.dart';
// 用于存储H264文件中解析出的帧
class H264Frame {
final Uint8List data;
final int frameType; // 0=I帧, 1=P帧
final int? refIFrameSeq;
H264Frame(this.data, this.frameType, [this.refIFrameSeq]);
}
// H264 NAL 单元类型
class NalUnitType {
static const int UNSPECIFIED = 0;
static const int CODED_SLICE_NON_IDR = 1; // P帧
static const int PARTITION_A = 2;
static const int PARTITION_B = 3;
static const int PARTITION_C = 4;
static const int CODED_SLICE_IDR = 5; // I帧
static const int SEI = 6; // 补充增强信息
static const int SPS = 7; // 序列参数集
static const int PPS = 8; // 图像参数集
static const int AUD = 9; // 访问单元分隔符
static const int END_SEQUENCE = 10; // 序列结束
static const int END_STREAM = 11; // 码流结束
static const int FILLER_DATA = 12; // 填充数据
static const int SPS_EXT = 13; // SPS扩展
static const int PREFIX_NAL = 14; // 前缀NAL单元
static const int SUBSET_SPS = 15; // 子集SPS
static const int DEPTH_PARAM_SET = 16; // 深度参数集
static const int RESERVED_17 = 17; // 保留类型
static const int RESERVED_18 = 18; // 保留类型
static const int CODED_SLICE_AUX = 19; // 辅助切片
static const int CODED_SLICE_EXTENSION = 20; // 扩展切片
static const int CODED_SLICE_DEPTH = 21; // 深度扩展
static const int RESERVED_22 = 22; // 保留类型
static const int RESERVED_23 = 23; // 保留类型
static const int STAP_A = 24; // 单时间聚合包A
static const int STAP_B = 25; // 单时间聚合包B
static const int MTAP_16 = 26; // 多时间聚合包16
static const int MTAP_24 = 27; // 多时间聚合包24
static const int FU_A = 28; // 分片单元A
static const int FU_B = 29; // 分片单元B
static const int RESERVED_30 = 30; // 保留类型
static const int RESERVED_31 = 31; // 保留类型
// 帧类型别名,方便调用
static const int NON_IDR_PICTURE = CODED_SLICE_NON_IDR; // P帧
static const int IDR_PICTURE = CODED_SLICE_IDR; // I帧
// 获取类型名称
static String getName(int type) {
switch (type) {
case UNSPECIFIED:
return "未指定";
case CODED_SLICE_NON_IDR:
return "P帧";
case PARTITION_A:
return "分区A";
case PARTITION_B:
return "分区B";
case PARTITION_C:
return "分区C";
case CODED_SLICE_IDR:
return "I帧";
case SEI:
return "SEI";
case SPS:
return "SPS";
case PPS:
return "PPS";
case AUD:
return "AUD";
case END_SEQUENCE:
return "序列结束";
case END_STREAM:
return "码流结束";
case FILLER_DATA:
return "填充数据";
case SPS_EXT:
return "SPS扩展";
case PREFIX_NAL:
return "前缀NAL";
case SUBSET_SPS:
return "子集SPS";
case CODED_SLICE_AUX:
return "辅助切片";
case CODED_SLICE_EXTENSION:
return "扩展切片";
case FU_A:
return "分片单元A";
case FU_B:
return "分片单元B";
default:
return "未知($type)";
}
}
}
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '视频解码演示',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const VideoView(),
);
}
}
class VideoView extends StatefulWidget {
const VideoView({Key? key}) : super(key: key);
@override
State<VideoView> createState() => _VideoViewState();
}
/// 视频解码主页面的状态管理类。
/// 负责:
/// 1. 加载和解析H264文件
/// 2. 初始化、释放视频解码器
/// 3. 控制视频播放、停止、帧解码流程
/// 4. 管理UI刷新与日志显示
/// 5. 维护与解码相关的核心状态如纹理ID、播放状态、错误信息等
///
/// 主要成员说明:
/// - _textureId: 当前解码器绑定的纹理ID
/// - _isInitialized: 解码器是否已初始化
/// - _isPlaying: 是否正在播放
/// - _statusText: 当前状态文本
/// - _error: 错误信息
/// - _h264FileData: 加载的H264文件数据
/// - _h264Frames: 解析出的帧列表
/// - _currentFrameIndex: 当前播放帧索引
/// - _frameTimer: 帧播放定时器
/// - _logs: 日志信息
/// - _logScrollController: 日志滚动控制器
class _VideoViewState extends State<VideoView> {
// 解码器状态
int? _textureId;
bool _isInitialized = false;
bool _isPlaying = false;
String _statusText = "未初始化";
String _error = "";
// 帧统计
int _renderedFrameCount = 0;
DateTime? _lastFrameTime;
double _fps = 0;
double _decoderFps = 0; // 解码器内部计算的FPS
// 用于刷新解码器统计信息的定时器
Timer? _statsTimer;
// 丢包模拟相关
bool _enablePacketLoss = false;
double _packetLossRate = 0.1; // 默认10%丢包率
bool _dropIFrames = false;
bool _dropPFrames = false;
bool _dropSPSPPS = false;
bool _burstPacketLossMode = false;
int _burstPacketLossCounter = 0;
int _droppedFramesCount = 0;
// H264文件解析
Uint8List? _h264FileData;
List<H264Frame> _h264Frames = [];
int _currentFrameIndex = 0;
// 解码定时器
Timer? _frameTimer;
// 日志
final List<String> _logs = [];
final ScrollController _logScrollController = ScrollController();
// 视频显示相关的属性
bool _showingErrorFrame = false;
Timer? _errorFrameResetTimer;
// 额外的解码器属性
int _totalFrames = 0;
int _droppedFrames = 0;
bool _hasSentIDR = false;
bool _hasSentSPS = false;
bool _hasSentPPS = false;
@override
void initState() {
super.initState();
_loadH264File();
// 仅在需要时使用定时器更新一些UI元素
_statsTimer = Timer.periodic(Duration(milliseconds: 1000), (timer) {
if (mounted) {
setState(() {
// 更新UI元素例如帧率计算等
// 解码器统计信息现在通过回调获取,不需要在这里请求
});
}
});
}
@override
void dispose() {
_stopPlaying();
_releaseDecoder();
_frameTimer?.cancel();
_statsTimer?.cancel();
super.dispose();
}
// 加载H264文件
Future<void> _loadH264File() async {
try {
_log("正在加载 demo.h264 文件...");
final ByteData data = await rootBundle.load('assets/demo.h264');
setState(() {
_h264FileData = data.buffer.asUint8List();
});
_log("H264文件加载完成: ${_h264FileData!.length} 字节");
// 解析H264文件
_parseH264File();
} catch (e) {
_log("加载H264文件失败: $e");
setState(() {
_error = "加载H264文件失败: $e";
});
}
}
// 解析H264文件提取NAL单元
void _parseH264File() {
if (_h264FileData == null) return;
_log("开始解析H264文件...");
List<H264Frame> frames = [];
int startIndex = 0;
while (startIndex < _h264FileData!.length - 4) {
int nextStartIndex = _findStartCode(_h264FileData!, startIndex + 3);
if (nextStartIndex == -1) {
nextStartIndex = _h264FileData!.length;
}
int skipBytes = (_h264FileData![startIndex] == 0x00 &&
_h264FileData![startIndex + 1] == 0x00 &&
_h264FileData![startIndex + 2] == 0x00 &&
_h264FileData![startIndex + 3] == 0x01)
? 4
: 3;
if (nextStartIndex > startIndex + skipBytes) {
int nalType = _h264FileData![startIndex + skipBytes] & 0x1F;
var nalData = Uint8List(nextStartIndex - startIndex);
for (int i = 0; i < nalData.length; i++) {
nalData[i] = _h264FileData![startIndex + i];
}
// 0=I帧, 1=P帧
if (nalType == 7 || nalType == 8 || nalType == 5) {
frames.add(H264Frame(nalData, 0));
} else {
frames.add(H264Frame(nalData, 1));
}
}
startIndex = nextStartIndex;
}
setState(() {
_h264Frames = frames;
});
_log("H264文件解析完成找到 " + frames.length.toString() + " 个帧");
}
// 查找起始码的辅助方法
int _findStartCode(Uint8List data, int offset) {
for (int i = offset; i < data.length - 3; i++) {
// 检查是否为0x000001
if (data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01) {
return i;
}
// 检查是否为0x00000001
if (i < data.length - 4 &&
data[i] == 0x00 &&
data[i + 1] == 0x00 &&
data[i + 2] == 0x00 &&
data[i + 3] == 0x01) {
return i;
}
}
return -1;
}
// 获取NAL类型
int _getNalType(Uint8List data) {
// 跳过起始码后再获取NAL单元类型
if (data.length > 4) {
// 检查是否有0x00000001的起始码
if (data[0] == 0x00 &&
data[1] == 0x00 &&
data[2] == 0x00 &&
data[3] == 0x01) {
// 起始码后的第一个字节的低5位是NAL类型
if (data.length > 4) {
return data[4] & 0x1F;
}
}
// 检查是否有0x000001的起始码
else if (data[0] == 0x00 && data[1] == 0x00 && data[2] == 0x01) {
// 起始码后的第一个字节的低5位是NAL类型
if (data.length > 3) {
return data[3] & 0x1F;
}
}
}
// 如果找不到起始码或数据不足,则尝试直接获取
if (data.length > 0) {
return data[0] & 0x1F;
}
return 0;
}
// 当新帧可用时调用
void _onFrameAvailable(int textureId) {
if (!mounted) return;
_log("收到帧回调 - 渲染帧 ${_renderedFrameCount + 1}");
// 更新帧时间用于FPS计算
_lastFrameTime = DateTime.now();
// 立即更新UI以显示新帧
setState(() {
_renderedFrameCount++;
});
}
Future<void> _initializeDecoder() async {
if (_isInitialized) {
await _releaseDecoder();
}
_log("正在初始化解码器");
try {
final config = VideoDecoderConfig(width: 640, height: 480);
final textureId = await VideoDecodePlugin.initDecoder(config);
if (textureId != null) {
_textureId = textureId;
setState(() {
_isInitialized = true;
_error = "";
_statusText = "就绪";
_renderedFrameCount = 0;
});
_log("解码器初始化成功纹理ID: $_textureId");
} else {
setState(() {
_error = "获取纹理ID失败";
_statusText = "初始化失败";
});
_log("解码器初始化失败 - 返回空纹理ID");
}
} catch (e) {
setState(() {
_error = e.toString();
_statusText = "初始化错误";
});
_log("解码器初始化错误: $e");
}
}
// 解码帧已适配sendFramesplitNalFromIFrame=false
Future<void> _decodeNextFrame(H264Frame frame, int frameSeq) async {
if (_textureId == null || !_isInitialized || !_isPlaying) {
return;
}
try {
final timestamp = DateTime.now().microsecondsSinceEpoch;
// 如果是I帧先发送SPS和PPS
int nalType = _getNalType(frame.data);
if (nalType == NalUnitType.CODED_SLICE_IDR) {
// 查找最近的SPS和PPS
H264Frame? sps, pps;
for (int i = frameSeq - 1; i >= 0; i--) {
int t = _getNalType(_h264Frames[i].data);
if (sps == null && t == NalUnitType.SPS) sps = _h264Frames[i];
if (pps == null && t == NalUnitType.PPS) pps = _h264Frames[i];
if (sps != null && pps != null) break;
}
if (sps != null) {
await VideoDecodePlugin.sendFrame(
frameData: sps.data,
frameType: 0,
timestamp: timestamp,
frameSeq: frameSeq - 2,
splitNalFromIFrame: false,
);
}
if (pps != null) {
await VideoDecodePlugin.sendFrame(
frameData: pps.data,
frameType: 0,
timestamp: timestamp,
frameSeq: frameSeq - 1,
splitNalFromIFrame: false,
);
}
}
await VideoDecodePlugin.sendFrame(
frameData: frame.data,
frameType: frame.frameType,
timestamp: timestamp,
frameSeq: frameSeq,
splitNalFromIFrame: false,
);
} catch (e) {
_log("解码帧错误: $e");
}
}
Future<void> _releaseDecoder() async {
if (_textureId != null) {
_log("正在释放解码器资源");
try {
await VideoDecodePlugin.releaseDecoder();
setState(() {
_textureId = null;
_isInitialized = false;
_statusText = "已释放";
});
_log("解码器资源释放成功");
} catch (e) {
_log("释放解码器错误: $e");
}
}
}
Future<void> _startPlaying() async {
if (!_isInitialized || _isPlaying || _h264Frames.isEmpty) {
_log(
"播放条件未满足: 初始化=${_isInitialized}, 播放中=${_isPlaying}, 帧数量=${_h264Frames.length}");
return;
}
_log("开始播放H264视频");
try {
// 重置帧率跟踪
_renderedFrameCount = 0;
_lastFrameTime = null;
_fps = 0;
_currentFrameIndex = 0;
// 确保首先发送SPS和PPS
await _sendSpsAndPps();
// 开始解码帧
_startDecodingFrames();
setState(() {
_isPlaying = true;
_statusText = "播放中";
});
_log("播放已开始");
} catch (e) {
_log("播放开始错误: $e");
}
}
// 发送SPS和PPS
Future<void> _sendSpsAndPps() async {
for (int i = 0; i < math.min(10, _h264Frames.length); i++) {
H264Frame frame = _h264Frames[i];
// 检查是否是SPS或PPS (通过检查NAL类型)
if (frame.data.length > 4) {
int skipBytes = (frame.data[0] == 0x00 &&
frame.data[1] == 0x00 &&
frame.data[2] == 0x00 &&
frame.data[3] == 0x01)
? 4
: 3;
if (skipBytes < frame.data.length) {
int nalType = frame.data[skipBytes] & 0x1F;
if (nalType == NalUnitType.SPS || nalType == NalUnitType.PPS) {
_log("发送${nalType == NalUnitType.SPS ? 'SPS' : 'PPS'}数据");
await _decodeNextFrame(frame, i);
// 发送后等待一小段时间,确保解码器处理
await Future.delayed(Duration(milliseconds: 30));
}
}
}
}
}
void _stopPlaying() {
if (!_isPlaying) return;
_log("停止播放");
_frameTimer?.cancel();
_frameTimer = null;
setState(() {
_isPlaying = false;
_statusText = "已停止";
});
_log("播放已停止");
}
void _startDecodingFrames() {
_log("开始解码视频帧");
// 使用与原视频接近的帧率
const int frameIntervalMs = 42; // 约23.8 fps (接近原视频23.9fps)
_frameTimer =
Timer.periodic(Duration(milliseconds: frameIntervalMs), (timer) async {
if (_currentFrameIndex >= _h264Frames.length) {
_log("所有帧已解码,重新开始");
_currentFrameIndex = 0;
}
final frame = _h264Frames[_currentFrameIndex];
// 未初始化成功时不发送解码帧
if (!_isInitialized || _textureId == null) return;
await _decodeNextFrame(frame, _currentFrameIndex);
_currentFrameIndex++;
});
}
void _log(String message) {
final timestamp = DateTime.now().toString().split('.').first;
final logMessage = "[$timestamp] $message";
setState(() {
_logs.add(logMessage);
if (_logs.length > 100) {
_logs.removeAt(0);
}
});
// 滚动到底部
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_logScrollController.hasClients) {
_logScrollController.animateTo(
_logScrollController.position.maxScrollExtent,
duration: Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Widget _buildVideoDisplay() {
if (_textureId == null) {
return Container(
width: 640,
height: 480,
color: Colors.black54,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.videocam_off, size: 48, color: Colors.white70),
SizedBox(height: 16),
Text(
'无可用纹理',
style: TextStyle(color: Colors.white, fontSize: 16),
),
],
),
),
);
}
return Stack(
fit: StackFit.expand,
children: [
// 背景色
Container(color: Colors.black),
// 无帧时显示加载指示
if (_renderedFrameCount == 0)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white70),
),
SizedBox(height: 16),
Text(
'初始化中...',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
// 视频纹理 - 使用RepaintBoundary和ValueKey确保正确更新
RepaintBoundary(
child: Texture(
textureId: _textureId!,
filterQuality: FilterQuality.medium,
key: ValueKey('texture_${_renderedFrameCount}'),
),
),
// 丢包效果指示器 - 当有帧被丢弃时,上方显示红色条带
if (_enablePacketLoss && _droppedFramesCount > 0)
Positioned(
top: 0,
left: 0,
right: 0,
height: 5,
child: Container(
color: Colors.red.withOpacity(0.8),
),
),
// // 显示帧计数 - 调试用
// Positioned(
// right: 10,
// top: 10,
// child: Container(
// padding: EdgeInsets.all(5),
// decoration: BoxDecoration(
// color: Colors.black.withOpacity(0.5),
// borderRadius: BorderRadius.circular(4),
// ),
// constraints: BoxConstraints(
// maxWidth: 150, // 限制最大宽度
// ),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.end,
// mainAxisSize: MainAxisSize.min, // 确保column只占用所需空间
// children: [
// Text(
// '帧: $_renderedFrameCount',
// style: TextStyle(color: Colors.white, fontSize: 12),
// ),
// if (_enablePacketLoss)
// Text(
// '丢帧: $_droppedFramesCount',
// style: TextStyle(
// color: _droppedFramesCount > 0
// ? Colors.orange
// : Colors.white70,
// fontSize: 12,
// ),
// ),
// ],
// ),
// ),
// ),
],
);
}
@override
Widget build(BuildContext context) {
// 更新FPS计算
if (_lastFrameTime != null && _renderedFrameCount > 0) {
final now = DateTime.now();
final elapsed = now.difference(_lastFrameTime!).inMilliseconds;
if (elapsed > 0) {
_fps = 1000 / elapsed;
}
}
return Scaffold(
appBar: AppBar(
title: Text('视频解码插件演示'),
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 视频显示区域 - 使用AspectRatio控制大小
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
),
child: _buildVideoDisplay(),
),
),
// 控制面板和状态信息 - 使用Expanded和SingleChildScrollView
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 状态信息区 - 使用Card使其更紧凑
Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 状态行
Text('状态: $_statusText',
style:
TextStyle(fontWeight: FontWeight.bold)),
// FPS和帧数信息
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Text(
// '解码器FPS: ${_decoderFps.toStringAsFixed(1)}',
// style: TextStyle(color: Colors.green),
// ),
// ),
//
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Text('已渲染帧数: $_renderedFrameCount'),
// ),
//
// // 丢弃帧信息
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Text(
// '已丢弃帧数: $_droppedFramesCount',
// style: TextStyle(
// color: _droppedFramesCount > 0
// ? Colors.orange
// : Colors.black),
// ),
// ),
//
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Text('当前帧索引: $_currentFrameIndex'),
// ),
//
// // 参数集状态
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text('参数集状态:'),
// Padding(
// padding: const EdgeInsets.only(
// left: 8.0, top: 2.0),
// child: Text(
// 'SPS: ${_hasSentSPS ? "已发送" : "未发送"}'),
// ),
// Padding(
// padding: const EdgeInsets.only(
// left: 8.0, top: 2.0),
// child: Text(
// 'PPS: ${_hasSentPPS ? "已发送" : "未发送"}'),
// ),
// Padding(
// padding: const EdgeInsets.only(
// left: 8.0, top: 2.0),
// child: Text(
// 'IDR: ${_hasSentIDR ? "已发送" : "未发送"}'),
// ),
// ],
// ),
// ),
//
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Text('总帧数: $_totalFrames'),
// ),
//
// // 错误信息
// if (_error.isNotEmpty)
// Padding(
// padding: const EdgeInsets.only(top: 4.0),
// child: Text(
// '错误: $_error',
// style: TextStyle(
// color: Colors.red,
// fontWeight: FontWeight.bold),
// ),
// ),
// H264文件信息
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('解析的帧数: ${_h264Frames.length}'),
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'H264文件大小: ${((_h264FileData?.length ?? 0) ~/ 1024)} KB'),
),
],
),
),
),
// 控制按钮区
Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed:
_isInitialized ? null : _initializeDecoder,
child: Text('初始化'),
),
ElevatedButton(
onPressed: (!_isInitialized ||
_isPlaying ||
_h264Frames.isEmpty)
? null
: _startPlaying,
child: Text('播放'),
),
ElevatedButton(
onPressed: (!_isPlaying) ? null : _stopPlaying,
child: Text('停止'),
),
ElevatedButton(
onPressed: (_textureId == null)
? null
: _releaseDecoder,
child: Text('释放'),
),
ElevatedButton(
onPressed: () {
setState(() {
// 强制重绘
_renderedFrameCount++;
});
},
child: Text('刷新'),
),
],
),
),
),
// 丢包模拟控制面板
Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text('丢包模拟',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16)),
Switch(
value: _enablePacketLoss,
onChanged: (value) {
setState(() {
_enablePacketLoss = value;
if (value) {
_droppedFramesCount = 0; // 重置丢帧计数
}
});
},
),
],
),
// 丢包率控制
Row(
children: [
Text(
'丢包率: ${(_packetLossRate * 100).toStringAsFixed(0)}%'),
Expanded(
child: Slider(
value: _packetLossRate,
min: 0.0,
max: 1.0,
divisions: 20,
onChanged: _enablePacketLoss
? (value) {
setState(() {
_packetLossRate = value;
});
}
: null,
),
),
],
),
// 丢包模式选择
Row(
children: [
Expanded(
child: CheckboxListTile(
dense: true,
title: Text('爆发式丢包'),
value: _burstPacketLossMode,
onChanged: _enablePacketLoss
? (value) {
setState(() {
_burstPacketLossMode = value!;
});
}
: null,
),
),
Expanded(
child: CheckboxListTile(
dense: true,
title: Text('丢弃I帧'),
value: _dropIFrames,
onChanged: _enablePacketLoss
? (value) {
setState(() {
_dropIFrames = value!;
});
}
: null,
),
),
],
),
Row(
children: [
Expanded(
child: CheckboxListTile(
dense: true,
title: Text('丢弃P帧'),
value: _dropPFrames,
onChanged: _enablePacketLoss
? (value) {
setState(() {
_dropPFrames = value!;
});
}
: null,
),
),
Expanded(
child: CheckboxListTile(
dense: true,
title: Text('丢弃SPS/PPS'),
value: _dropSPSPPS,
onChanged: _enablePacketLoss
? (value) {
setState(() {
_dropSPSPPS = value!;
});
}
: null,
),
),
],
),
// 丢包统计信息
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'已丢弃帧数: $_droppedFramesCount',
style: TextStyle(
color: _droppedFramesCount > 0
? Colors.red
: Colors.black),
),
),
],
),
),
),
// 日志区域
SizedBox(height: 8),
Text('日志:',
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Container(
height: 280,
decoration: BoxDecoration(
color: Colors.black,
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4.0),
),
padding: EdgeInsets.all(4),
child: ListView.builder(
controller: _logScrollController,
itemCount: _logs.length,
itemBuilder: (context, index) {
return Text(
_logs[index],
style: TextStyle(
color: _logs[index].contains('错误')
? Colors.red
: Colors.green,
fontSize: 11,
fontFamily: 'monospace',
),
);
},
),
),
],
),
),
),
),
],
),
),
);
}
}
// 添加错误帧绘制器
class ErrorFramePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final random = math.Random();
final paint = Paint()
..color = Colors.red.withOpacity(0.2)
..style = PaintingStyle.fill;
// 绘制随机条纹效果
for (int i = 0; i < 20; i++) {
double y = random.nextDouble() * size.height;
canvas.drawRect(
Rect.fromLTWH(0, y, size.width, 3 + random.nextDouble() * 5),
paint,
);
}
// 绘制马赛克块
for (int i = 0; i < 50; i++) {
double x = random.nextDouble() * size.width;
double y = random.nextDouble() * size.height;
double blockSize = 10 + random.nextDouble() * 30;
canvas.drawRect(
Rect.fromLTWH(x, y, blockSize, blockSize),
Paint()
..color = Color.fromRGBO(random.nextInt(255), random.nextInt(255),
random.nextInt(255), 0.3));
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true; // 每次都重新绘制以产生动态效果
}
}