2025-04-21 10:56:28 +08:00
|
|
|
|
import 'dart:async';
|
2025-04-21 15:11:23 +08:00
|
|
|
|
import 'dart:io';
|
2025-04-21 10:56:28 +08:00
|
|
|
|
import 'dart:typed_data';
|
2025-04-21 15:11:23 +08:00
|
|
|
|
import 'dart:math' as math;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
|
import 'package:video_decode_plugin/video_decode_plugin.dart';
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 用于存储H264文件中解析出的帧
|
|
|
|
|
|
class H264Frame {
|
|
|
|
|
|
final Uint8List data;
|
|
|
|
|
|
final FrameType type;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
H264Frame(this.data, this.type);
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// H264 NAL 单元类型
|
|
|
|
|
|
class NalUnitType {
|
|
|
|
|
|
static const int UNSPECIFIED = 0;
|
|
|
|
|
|
static const int CODED_SLICE_NON_IDR = 1; // P帧
|
2025-04-23 10:37:53 +08:00
|
|
|
|
static const int PARTITION_A = 2;
|
|
|
|
|
|
static const int PARTITION_B = 3;
|
|
|
|
|
|
static const int PARTITION_C = 4;
|
2025-04-21 15:11:23 +08:00
|
|
|
|
static const int CODED_SLICE_IDR = 5; // I帧
|
2025-04-23 10:37:53 +08:00
|
|
|
|
static const int SEI = 6; // 补充增强信息
|
2025-04-21 15:11:23 +08:00
|
|
|
|
static const int SPS = 7; // 序列参数集
|
|
|
|
|
|
static const int PPS = 8; // 图像参数集
|
2025-04-23 10:37:53 +08:00
|
|
|
|
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帧
|
2025-04-21 15:11:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取类型名称
|
|
|
|
|
|
static String getName(int type) {
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case UNSPECIFIED:
|
|
|
|
|
|
return "未指定";
|
|
|
|
|
|
case CODED_SLICE_NON_IDR:
|
|
|
|
|
|
return "P帧";
|
2025-04-23 10:37:53 +08:00
|
|
|
|
case PARTITION_A:
|
|
|
|
|
|
return "分区A";
|
|
|
|
|
|
case PARTITION_B:
|
|
|
|
|
|
return "分区B";
|
|
|
|
|
|
case PARTITION_C:
|
|
|
|
|
|
return "分区C";
|
2025-04-21 15:11:23 +08:00
|
|
|
|
case CODED_SLICE_IDR:
|
|
|
|
|
|
return "I帧";
|
2025-04-23 10:37:53 +08:00
|
|
|
|
case SEI:
|
|
|
|
|
|
return "SEI";
|
2025-04-21 15:11:23 +08:00
|
|
|
|
case SPS:
|
|
|
|
|
|
return "SPS";
|
|
|
|
|
|
case PPS:
|
|
|
|
|
|
return "PPS";
|
2025-04-23 10:37:53 +08:00
|
|
|
|
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";
|
2025-04-21 15:11:23 +08:00
|
|
|
|
default:
|
|
|
|
|
|
return "未知($type)";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
void main() {
|
|
|
|
|
|
runApp(const MyApp());
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
class MyApp extends StatelessWidget {
|
|
|
|
|
|
const MyApp({Key? key}) : super(key: key);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return MaterialApp(
|
|
|
|
|
|
title: '视频解码演示',
|
|
|
|
|
|
theme: ThemeData(
|
|
|
|
|
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
|
|
|
|
|
useMaterial3: true,
|
|
|
|
|
|
),
|
|
|
|
|
|
home: const VideoView(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
class VideoView extends StatefulWidget {
|
|
|
|
|
|
const VideoView({Key? key}) : super(key: key);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
@override
|
|
|
|
|
|
State<VideoView> createState() => _VideoViewState();
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
class _VideoViewState extends State<VideoView> {
|
|
|
|
|
|
// 解码器状态
|
|
|
|
|
|
int? _textureId;
|
|
|
|
|
|
bool _isInitialized = false;
|
|
|
|
|
|
bool _isPlaying = false;
|
|
|
|
|
|
String _statusText = "未初始化";
|
|
|
|
|
|
String _error = "";
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 解码器状态信息
|
|
|
|
|
|
DecoderState _decoderState = DecoderState.initializing;
|
|
|
|
|
|
String _decoderStateText = "初始化中";
|
|
|
|
|
|
bool _isActuallyRendering = false; // 区分预通知和实际渲染状态
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 帧统计
|
|
|
|
|
|
int _renderedFrameCount = 0;
|
|
|
|
|
|
DateTime? _lastFrameTime;
|
|
|
|
|
|
double _fps = 0;
|
2025-04-23 10:37:53 +08:00
|
|
|
|
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;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// H264文件解析
|
|
|
|
|
|
Uint8List? _h264FileData;
|
|
|
|
|
|
List<H264Frame> _h264Frames = [];
|
|
|
|
|
|
int _currentFrameIndex = 0;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 解码定时器
|
|
|
|
|
|
Timer? _frameTimer;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 日志
|
|
|
|
|
|
final List<String> _logs = [];
|
|
|
|
|
|
final ScrollController _logScrollController = ScrollController();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 视频显示相关的属性
|
|
|
|
|
|
bool _showingErrorFrame = false;
|
|
|
|
|
|
Timer? _errorFrameResetTimer;
|
|
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 额外的解码器属性
|
|
|
|
|
|
int _totalFrames = 0;
|
|
|
|
|
|
int _droppedFrames = 0;
|
|
|
|
|
|
bool _hasSentIDR = false;
|
|
|
|
|
|
bool _hasSentSPS = false;
|
|
|
|
|
|
bool _hasSentPPS = false;
|
|
|
|
|
|
|
2025-04-21 10:56:28 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
_loadH264File();
|
2025-04-23 10:37:53 +08:00
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 仅在需要时使用定时器更新一些UI元素
|
2025-04-23 10:37:53 +08:00
|
|
|
|
_statsTimer = Timer.periodic(Duration(milliseconds: 1000), (timer) {
|
2025-04-23 16:36:55 +08:00
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
// 更新UI元素,例如帧率计算等
|
|
|
|
|
|
// 解码器统计信息现在通过回调获取,不需要在这里请求
|
|
|
|
|
|
});
|
2025-04-23 10:37:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_stopPlaying();
|
|
|
|
|
|
_releaseDecoder();
|
|
|
|
|
|
_frameTimer?.cancel();
|
2025-04-23 16:36:55 +08:00
|
|
|
|
_statsTimer?.cancel();
|
2025-04-21 15:11:23 +08:00
|
|
|
|
super.dispose();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 加载H264文件
|
2025-04-21 10:56:28 +08:00
|
|
|
|
Future<void> _loadH264File() async {
|
|
|
|
|
|
try {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("正在加载 demo.h264 文件...");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
final ByteData data = await rootBundle.load('assets/demo.h264');
|
|
|
|
|
|
setState(() {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_h264FileData = data.buffer.asUint8List();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
});
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("H264文件加载完成: ${_h264FileData!.length} 字节");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 解析H264文件
|
2025-04-21 10:56:28 +08:00
|
|
|
|
_parseH264File();
|
|
|
|
|
|
} catch (e) {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("加载H264文件失败: $e");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
setState(() {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_error = "加载H264文件失败: $e";
|
2025-04-21 10:56:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 解析H264文件,提取NAL单元
|
2025-04-21 10:56:28 +08:00
|
|
|
|
void _parseH264File() {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
if (_h264FileData == null) return;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("开始解析H264文件...");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
List<H264Frame> frames = [];
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 查找起始码 0x00000001 或 0x000001
|
|
|
|
|
|
int startIndex = 0;
|
|
|
|
|
|
bool hasSps = false;
|
|
|
|
|
|
bool hasPps = false;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
while (startIndex < _h264FileData!.length - 4) {
|
|
|
|
|
|
// 查找下一个起始码
|
|
|
|
|
|
int nextStartIndex = _findStartCode(_h264FileData!, startIndex + 3);
|
|
|
|
|
|
if (nextStartIndex == -1) {
|
|
|
|
|
|
nextStartIndex = _h264FileData!.length;
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 提取NAL单元,跳过起始码(3或4字节)
|
|
|
|
|
|
int skipBytes = (_h264FileData![startIndex] == 0x00 &&
|
|
|
|
|
|
_h264FileData![startIndex + 1] == 0x00 &&
|
|
|
|
|
|
_h264FileData![startIndex + 2] == 0x00 &&
|
|
|
|
|
|
_h264FileData![startIndex + 3] == 0x01)
|
|
|
|
|
|
? 4
|
|
|
|
|
|
: 3;
|
|
|
|
|
|
|
|
|
|
|
|
if (nextStartIndex > startIndex + skipBytes) {
|
|
|
|
|
|
// 获取NAL类型
|
|
|
|
|
|
int nalType = _h264FileData![startIndex + skipBytes] & 0x1F;
|
|
|
|
|
|
|
|
|
|
|
|
// 创建NAL单元数据
|
|
|
|
|
|
var nalData = Uint8List(nextStartIndex - startIndex);
|
|
|
|
|
|
for (int i = 0; i < nalData.length; i++) {
|
|
|
|
|
|
nalData[i] = _h264FileData![startIndex + i];
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 根据NAL类型分类
|
|
|
|
|
|
switch (nalType) {
|
|
|
|
|
|
case NalUnitType.SPS:
|
|
|
|
|
|
_log("找到SPS: 位置=${startIndex}, 长度=${nalData.length}");
|
|
|
|
|
|
hasSps = true;
|
|
|
|
|
|
frames.add(H264Frame(nalData, FrameType.iFrame));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case NalUnitType.PPS:
|
|
|
|
|
|
_log("找到PPS: 位置=${startIndex}, 长度=${nalData.length}");
|
|
|
|
|
|
hasPps = true;
|
|
|
|
|
|
frames.add(H264Frame(nalData, FrameType.iFrame));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case NalUnitType.CODED_SLICE_IDR:
|
|
|
|
|
|
_log("找到I帧: 位置=${startIndex}, 长度=${nalData.length}");
|
|
|
|
|
|
frames.add(H264Frame(nalData, FrameType.iFrame));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case NalUnitType.CODED_SLICE_NON_IDR:
|
|
|
|
|
|
frames.add(H264Frame(nalData, FrameType.pFrame));
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
// 其他类型的NAL单元也添加进去
|
|
|
|
|
|
frames.add(H264Frame(nalData, FrameType.pFrame));
|
|
|
|
|
|
break;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
startIndex = nextStartIndex;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_h264Frames = frames;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("H264文件解析完成,找到 ${frames.length} 个帧,包含SPS=${hasSps}, PPS=${hasPps}");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 查找起始码的辅助方法
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
2025-04-21 15:11:23 +08:00
|
|
|
|
return -1;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 获取NAL类型
|
|
|
|
|
|
// 获取NAL类型
|
2025-04-21 15:11:23 +08:00
|
|
|
|
int _getNalType(Uint8List data) {
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 跳过起始码后再获取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;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-21 15:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 如果找不到起始码或数据不足,则尝试直接获取
|
|
|
|
|
|
if (data.length > 0) {
|
|
|
|
|
|
return data[0] & 0x1F;
|
2025-04-21 15:11:23 +08:00
|
|
|
|
}
|
2025-04-23 10:37:53 +08:00
|
|
|
|
return 0;
|
2025-04-21 15:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 当新帧可用时调用
|
|
|
|
|
|
void _onFrameAvailable(int textureId) {
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
_log("收到帧回调 - 渲染帧 ${_renderedFrameCount + 1}");
|
|
|
|
|
|
|
|
|
|
|
|
// 更新帧时间用于FPS计算
|
|
|
|
|
|
_lastFrameTime = DateTime.now();
|
|
|
|
|
|
|
|
|
|
|
|
// 立即更新UI以显示新帧
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_renderedFrameCount++;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
Future<void> _initializeDecoder() async {
|
|
|
|
|
|
if (_isInitialized) {
|
|
|
|
|
|
await _releaseDecoder();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("正在初始化解码器");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
try {
|
2025-04-21 10:56:28 +08:00
|
|
|
|
final config = VideoDecoderConfig(
|
2025-04-21 15:11:23 +08:00
|
|
|
|
width: 640,
|
|
|
|
|
|
height: 480,
|
2025-04-21 10:56:28 +08:00
|
|
|
|
codecType: CodecType.h264,
|
2025-04-23 16:36:55 +08:00
|
|
|
|
frameRate: 24, // 设置为接近原视频的24fps (23.976)
|
2025-04-21 15:11:23 +08:00
|
|
|
|
isDebug: true, // 打开调试日志
|
2025-04-21 10:56:28 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
final textureId = await VideoDecodePlugin.initDecoder(config);
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
if (textureId != null) {
|
|
|
|
|
|
_textureId = textureId;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 设置帧回调
|
|
|
|
|
|
VideoDecodePlugin.setFrameCallbackForTexture(
|
|
|
|
|
|
textureId, _onFrameAvailable);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 设置状态回调
|
|
|
|
|
|
VideoDecodePlugin.setStateCallbackForTexture(
|
|
|
|
|
|
textureId, _onDecoderStateChanged);
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_isInitialized = true;
|
|
|
|
|
|
_error = "";
|
|
|
|
|
|
_statusText = "就绪";
|
|
|
|
|
|
_renderedFrameCount = 0; // 重置帧计数
|
2025-04-23 16:36:55 +08:00
|
|
|
|
_decoderState = DecoderState.initializing;
|
|
|
|
|
|
_decoderStateText = "初始化中";
|
|
|
|
|
|
_isActuallyRendering = false;
|
2025-04-21 15:11:23 +08:00
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("解码器初始化成功,纹理ID: $_textureId");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 自动发送测试帧以触发渲染
|
|
|
|
|
|
await _sendTestIFrame();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_error = "获取纹理ID失败";
|
|
|
|
|
|
_statusText = "初始化失败";
|
|
|
|
|
|
});
|
|
|
|
|
|
_log("解码器初始化失败 - 返回空纹理ID");
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setState(() {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_error = e.toString();
|
|
|
|
|
|
_statusText = "初始化错误";
|
2025-04-21 10:56:28 +08:00
|
|
|
|
});
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("解码器初始化错误: $e");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 解码器状态变化回调
|
|
|
|
|
|
void _onDecoderStateChanged(
|
|
|
|
|
|
int textureId, DecoderState state, Map<String, dynamic> stats) {
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
String stateText;
|
|
|
|
|
|
switch (state) {
|
|
|
|
|
|
case DecoderState.initializing:
|
|
|
|
|
|
stateText = "初始化中";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case DecoderState.ready:
|
|
|
|
|
|
stateText = "准备就绪";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case DecoderState.rendering:
|
|
|
|
|
|
stateText = "渲染中";
|
|
|
|
|
|
// 标记实际渲染状态
|
|
|
|
|
|
_isActuallyRendering = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case DecoderState.error:
|
|
|
|
|
|
stateText = "出错";
|
|
|
|
|
|
// 获取错误信息
|
|
|
|
|
|
final errorMessage = stats['errorMessage'] as String?;
|
|
|
|
|
|
if (errorMessage != null) {
|
|
|
|
|
|
_log("解码器错误: $errorMessage");
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
case DecoderState.released:
|
|
|
|
|
|
stateText = "已释放";
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
stateText = "未知状态";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新解码器状态UI
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_decoderState = state;
|
|
|
|
|
|
_decoderStateText = stateText;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新统计信息
|
|
|
|
|
|
if (stats.isNotEmpty) {
|
|
|
|
|
|
_decoderFps = (stats['fps'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
|
|
_renderedFrameCount = (stats['renderedFrames'] as int?) ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新更多统计信息
|
|
|
|
|
|
_totalFrames = (stats['totalFrames'] as int?) ?? 0;
|
|
|
|
|
|
_droppedFrames = (stats['droppedFrames'] as int?) ?? 0;
|
|
|
|
|
|
_hasSentIDR = (stats['hasSentIDR'] as bool?) ?? false;
|
|
|
|
|
|
_hasSentSPS = (stats['hasSentSPS'] as bool?) ?? false;
|
|
|
|
|
|
_hasSentPPS = (stats['hasSentPPS'] as bool?) ?? false;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新状态文本
|
|
|
|
|
|
if (state == DecoderState.rendering) {
|
|
|
|
|
|
_statusText = _isPlaying
|
|
|
|
|
|
? "播放中 (解码总帧: $_totalFrames, 丢弃: $_droppedFrames)"
|
|
|
|
|
|
: "已停止";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
String decoderInfo = "解码器状态更新: $_decoderStateText, " +
|
|
|
|
|
|
"帧数据: 渲染=$_renderedFrameCount, 总计=$_totalFrames, 丢弃=$_droppedFrames, " +
|
|
|
|
|
|
"FPS=${_decoderFps.toStringAsFixed(1)}, " +
|
|
|
|
|
|
"参数集: SPS=${_hasSentSPS}, PPS=${_hasSentPPS}, IDR=${_hasSentIDR}";
|
|
|
|
|
|
|
|
|
|
|
|
_log(decoderInfo);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 添加一个测试I帧来触发渲染
|
|
|
|
|
|
Future<void> _sendTestIFrame() async {
|
|
|
|
|
|
if (_textureId == null || !_isInitialized) {
|
|
|
|
|
|
_log("解码器未准备好,无法发送测试帧");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("生成并发送测试I帧");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 创建一个简单的NAL单元 (IDR帧)
|
|
|
|
|
|
// 5字节的起始码 + NAL类型5(I帧) + 一些简单的数据
|
|
|
|
|
|
List<int> testFrameData = [
|
|
|
|
|
|
0x00, 0x00, 0x00, 0x01, 0x65, // 起始码 + NAL类型 (0x65 = 101|0101 -> 类型5)
|
|
|
|
|
|
0x88, 0x84, 0x21, 0x43, 0x14, 0x56, 0x32, 0x80 // 一些随机数据
|
|
|
|
|
|
];
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
Uint8List testFrame = Uint8List.fromList(testFrameData);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
_log("发送测试I帧: ${testFrame.length} 字节");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
bool success = await VideoDecodePlugin.decodeFrameForTexture(
|
|
|
|
|
|
_textureId!, testFrame, FrameType.iFrame);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("测试I帧发送结果: ${success ? '成功' : '失败'}");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
} catch (e) {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("发送测试帧错误: $e");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
Future<void> _releaseDecoder() async {
|
2025-04-23 16:36:55 +08:00
|
|
|
|
_statsTimer?.cancel(); // 取消统计信息定时器
|
2025-04-21 10:56:28 +08:00
|
|
|
|
if (_textureId != null) {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("正在释放解码器资源");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await VideoDecodePlugin.releaseDecoderForTexture(_textureId!);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_textureId = null;
|
|
|
|
|
|
_isInitialized = false;
|
|
|
|
|
|
_statusText = "已释放";
|
2025-04-23 16:36:55 +08:00
|
|
|
|
_isActuallyRendering = false;
|
|
|
|
|
|
_decoderState = DecoderState.released;
|
|
|
|
|
|
_decoderStateText = "已释放";
|
2025-04-21 15:11:23 +08:00
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("解码器资源释放成功");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_log("释放解码器错误: $e");
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
2025-04-21 15:11:23 +08:00
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
Future<void> _startPlaying() async {
|
|
|
|
|
|
if (!_isInitialized || _isPlaying || _h264Frames.isEmpty) {
|
|
|
|
|
|
_log(
|
|
|
|
|
|
"播放条件未满足: 初始化=${_isInitialized}, 播放中=${_isPlaying}, 帧数量=${_h264Frames.length}");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("开始播放H264视频");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 重置帧率跟踪
|
|
|
|
|
|
_renderedFrameCount = 0;
|
|
|
|
|
|
_lastFrameTime = null;
|
|
|
|
|
|
_fps = 0;
|
|
|
|
|
|
_currentFrameIndex = 0;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 确保首先发送SPS和PPS
|
|
|
|
|
|
await _sendSpsAndPps();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 开始解码帧
|
|
|
|
|
|
_startDecodingFrames();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_isPlaying = true;
|
|
|
|
|
|
_statusText = "播放中";
|
|
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("播放已开始");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_log("播放开始错误: $e");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 发送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);
|
|
|
|
|
|
// 发送后等待一小段时间,确保解码器处理
|
|
|
|
|
|
await Future.delayed(Duration(milliseconds: 30));
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-21 15:11:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
void _stopPlaying() {
|
|
|
|
|
|
if (!_isPlaying) return;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("停止播放");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_frameTimer?.cancel();
|
|
|
|
|
|
_frameTimer = null;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_isPlaying = false;
|
|
|
|
|
|
_statusText = "已停止";
|
|
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("播放已停止");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
void _startDecodingFrames() {
|
|
|
|
|
|
_log("开始解码视频帧");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 使用与原视频接近的帧率
|
|
|
|
|
|
const int frameIntervalMs = 42; // 约23.8 fps (接近原视频23.9fps)
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_frameTimer =
|
|
|
|
|
|
Timer.periodic(Duration(milliseconds: frameIntervalMs), (timer) async {
|
|
|
|
|
|
if (_currentFrameIndex >= _h264Frames.length) {
|
|
|
|
|
|
_log("所有帧已解码,重新开始");
|
|
|
|
|
|
_currentFrameIndex = 0;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 重新发送SPS和PPS
|
|
|
|
|
|
await _sendSpsAndPps();
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
final frame = _h264Frames[_currentFrameIndex];
|
2025-04-23 10:37:53 +08:00
|
|
|
|
bool decodeSuccess = await _decodeNextFrame(frame);
|
|
|
|
|
|
|
|
|
|
|
|
// 只有在成功解码的情况下才显示日志信息
|
|
|
|
|
|
if (!decodeSuccess && _enablePacketLoss) {
|
|
|
|
|
|
_log("跳过索引 $_currentFrameIndex 的帧(丢帧模拟)");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 无论解码是否成功,都移动到下一帧
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_currentFrameIndex++;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
Future<bool> _decodeNextFrame(H264Frame frame) async {
|
|
|
|
|
|
if (_textureId == null || !_isInitialized || !_isPlaying) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
try {
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 获取NAL类型
|
2025-04-21 15:11:23 +08:00
|
|
|
|
int nalType = _getNalType(frame.data);
|
|
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 模拟丢包
|
|
|
|
|
|
if (_enablePacketLoss) {
|
|
|
|
|
|
bool shouldDrop = false;
|
|
|
|
|
|
|
|
|
|
|
|
// 爆发式丢包模式
|
|
|
|
|
|
if (_burstPacketLossMode && _burstPacketLossCounter > 0) {
|
|
|
|
|
|
shouldDrop = true;
|
|
|
|
|
|
_burstPacketLossCounter--;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 随机丢包
|
|
|
|
|
|
else if (math.Random().nextDouble() < _packetLossRate) {
|
|
|
|
|
|
shouldDrop = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 触发爆发式丢包
|
|
|
|
|
|
if (_burstPacketLossMode) {
|
|
|
|
|
|
_burstPacketLossCounter = math.Random().nextInt(5) + 1; // 随机爆发1-5个包
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 特定类型NAL的丢包策略
|
|
|
|
|
|
if (nalType == NalUnitType.CODED_SLICE_IDR && _dropIFrames) {
|
|
|
|
|
|
shouldDrop = true;
|
|
|
|
|
|
} else if ((nalType == NalUnitType.CODED_SLICE_NON_IDR ||
|
|
|
|
|
|
nalType == NalUnitType.CODED_SLICE_EXTENSION) &&
|
|
|
|
|
|
_dropPFrames) {
|
|
|
|
|
|
shouldDrop = true;
|
|
|
|
|
|
} else if ((nalType == NalUnitType.SPS || nalType == NalUnitType.PPS) &&
|
|
|
|
|
|
_dropSPSPPS) {
|
|
|
|
|
|
shouldDrop = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldDrop) {
|
|
|
|
|
|
_droppedFramesCount++;
|
|
|
|
|
|
String nalTypeName = NalUnitType.getName(nalType);
|
|
|
|
|
|
_log("丢弃帧:NAL类型 = $nalTypeName");
|
|
|
|
|
|
|
|
|
|
|
|
// 显示丢帧效果
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_showingErrorFrame = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 1秒后重置丢帧效果指示器
|
|
|
|
|
|
_errorFrameResetTimer?.cancel();
|
|
|
|
|
|
_errorFrameResetTimer = Timer(Duration(milliseconds: 1000), () {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_showingErrorFrame = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return false; // 直接返回false,不进行解码
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解码帧
|
2025-04-21 15:11:23 +08:00
|
|
|
|
final success = await VideoDecodePlugin.decodeFrameForTexture(
|
|
|
|
|
|
_textureId!,
|
|
|
|
|
|
frame.data,
|
|
|
|
|
|
frame.type,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!success) {
|
2025-04-23 10:37:53 +08:00
|
|
|
|
_log("解码帧失败,索引 $_currentFrameIndex (${frame.type})");
|
2025-04-21 15:11:23 +08:00
|
|
|
|
} else {
|
2025-04-23 10:37:53 +08:00
|
|
|
|
String nalTypeName = NalUnitType.getName(nalType);
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log(
|
2025-04-23 10:37:53 +08:00
|
|
|
|
"解码帧成功,索引 $_currentFrameIndex (${frame.type}), NAL类型: $nalTypeName");
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
2025-04-23 10:37:53 +08:00
|
|
|
|
return success;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
} catch (e) {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
_log("解码帧错误: $e");
|
2025-04-23 10:37:53 +08:00
|
|
|
|
return false;
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
void _log(String message) {
|
|
|
|
|
|
final timestamp = DateTime.now().toString().split('.').first;
|
|
|
|
|
|
final logMessage = "[$timestamp] $message";
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_logs.add(logMessage);
|
|
|
|
|
|
if (_logs.length > 100) {
|
|
|
|
|
|
_logs.removeAt(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 滚动到底部
|
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
|
if (_logScrollController.hasClients) {
|
|
|
|
|
|
_logScrollController.animateTo(
|
|
|
|
|
|
_logScrollController.position.maxScrollExtent,
|
|
|
|
|
|
duration: Duration(milliseconds: 200),
|
|
|
|
|
|
curve: Curves.easeOut,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
Widget _buildVideoDisplay() {
|
|
|
|
|
|
if (_textureId == null) {
|
2025-04-23 16:36:55 +08:00
|
|
|
|
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(
|
2025-04-21 15:11:23 +08:00
|
|
|
|
'无可用纹理',
|
2025-04-23 16:36:55 +08:00
|
|
|
|
style: TextStyle(color: Colors.white, fontSize: 16),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
2025-04-23 16:36:55 +08:00
|
|
|
|
],
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
return Stack(
|
|
|
|
|
|
fit: StackFit.expand,
|
2025-04-21 10:56:28 +08:00
|
|
|
|
children: [
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 背景色
|
|
|
|
|
|
Container(color: Colors.black),
|
|
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 无帧时显示加载指示
|
|
|
|
|
|
if (_renderedFrameCount == 0 || !_isActuallyRendering)
|
|
|
|
|
|
Center(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
CircularProgressIndicator(
|
|
|
|
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white70),
|
|
|
|
|
|
),
|
|
|
|
|
|
SizedBox(height: 16),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
_decoderState == DecoderState.initializing
|
|
|
|
|
|
? '初始化中...'
|
|
|
|
|
|
: _decoderState == DecoderState.ready
|
|
|
|
|
|
? '准备就绪,等待首帧...'
|
|
|
|
|
|
: '加载中...',
|
|
|
|
|
|
style: TextStyle(color: Colors.white70, fontSize: 14),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 视频纹理 - 使用RepaintBoundary和ValueKey确保正确更新
|
|
|
|
|
|
RepaintBoundary(
|
|
|
|
|
|
child: Texture(
|
|
|
|
|
|
textureId: _textureId!,
|
|
|
|
|
|
filterQuality: FilterQuality.medium,
|
|
|
|
|
|
key: ValueKey('texture_${_renderedFrameCount}'),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 10:56:28 +08:00
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 丢包效果指示器 - 当有帧被丢弃时,上方显示红色条带
|
|
|
|
|
|
if (_enablePacketLoss && _droppedFramesCount > 0)
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
height: 5,
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
color: Colors.red.withOpacity(0.8),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 解码器状态指示
|
|
|
|
|
|
if (_decoderState == DecoderState.error)
|
|
|
|
|
|
Container(
|
|
|
|
|
|
color: Colors.red.withOpacity(0.3),
|
|
|
|
|
|
child: Center(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Icon(Icons.error_outline, size: 48, color: Colors.white),
|
|
|
|
|
|
SizedBox(height: 16),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
'解码器错误',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
fontWeight: FontWeight.bold),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 显示帧计数 - 调试用
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
right: 10,
|
|
|
|
|
|
top: 10,
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding: EdgeInsets.all(5),
|
2025-04-23 16:36:55 +08:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2025-04-21 10:56:28 +08:00
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2025-04-21 10:56:28 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 更新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,
|
2025-04-21 10:56:28 +08:00
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
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,
|
2025-04-21 10:56:28 +08:00
|
|
|
|
children: [
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 状态信息区 - 使用Card使其更紧凑
|
|
|
|
|
|
Card(
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 状态行
|
|
|
|
|
|
Text('状态: $_statusText',
|
|
|
|
|
|
style:
|
|
|
|
|
|
TextStyle(fontWeight: FontWeight.bold)),
|
|
|
|
|
|
|
|
|
|
|
|
// 解码器状态行
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 4.0),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
'解码器状态: $_decoderStateText',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: _getStateColor(),
|
|
|
|
|
|
fontWeight: FontWeight.bold),
|
|
|
|
|
|
),
|
2025-04-23 10:37:53 +08:00
|
|
|
|
),
|
2025-04-23 16:36:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 实际渲染状态
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 4.0),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
'实际渲染: ${_isActuallyRendering ? "是" : "否"}',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: _isActuallyRendering
|
|
|
|
|
|
? Colors.green
|
|
|
|
|
|
: Colors.orange,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
2025-04-23 16:36:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 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 ? "已发送" : "未发送"}'),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
2025-04-23 10:37:53 +08:00
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 4.0),
|
|
|
|
|
|
child: Text('总帧数: $_totalFrames'),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
|
|
// 错误信息
|
|
|
|
|
|
if (_error.isNotEmpty)
|
2025-04-23 10:37:53 +08:00
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 4.0),
|
2025-04-23 16:36:55 +08:00
|
|
|
|
child: Text(
|
|
|
|
|
|
'错误: $_error',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: Colors.red,
|
|
|
|
|
|
fontWeight: FontWeight.bold),
|
2025-04-23 10:37:53 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-23 16:36:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 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'),
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
2025-04-21 10:56:28 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
|
|
|
|
|
|
// 控制按钮区
|
|
|
|
|
|
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('刷新'),
|
2025-04-21 10:56:28 +08:00
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
],
|
2025-04-21 10:56:28 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
|
|
|
|
|
|
2025-04-23 10:37:53 +08:00
|
|
|
|
// 丢包模拟控制面板
|
|
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
|
2025-04-21 15:11:23 +08:00
|
|
|
|
// 日志区域
|
|
|
|
|
|
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',
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 10:56:28 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2025-04-21 15:11:23 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
2025-04-21 10:56:28 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-04-23 10:37:53 +08:00
|
|
|
|
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 此方法保留用于手动获取最新统计信息,不再需要定时调用
|
2025-04-23 10:37:53 +08:00
|
|
|
|
Future<void> _updateDecoderStats() async {
|
|
|
|
|
|
if (_textureId == null || !_isInitialized) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-04-23 16:36:55 +08:00
|
|
|
|
// 获取所有解码器统计信息
|
|
|
|
|
|
final stats = await VideoDecodePlugin.getDecoderStats(_textureId!);
|
2025-04-23 10:37:53 +08:00
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
2025-04-23 16:36:55 +08:00
|
|
|
|
_log("手动更新解码器统计信息: $stats");
|
2025-04-23 10:37:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
_log("获取解码器统计信息失败: $e");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-23 16:36:55 +08:00
|
|
|
|
|
|
|
|
|
|
Color _getStateColor() {
|
|
|
|
|
|
switch (_decoderState) {
|
|
|
|
|
|
case DecoderState.initializing:
|
|
|
|
|
|
return Colors.orange;
|
|
|
|
|
|
case DecoderState.ready:
|
|
|
|
|
|
return Colors.green;
|
|
|
|
|
|
case DecoderState.rendering:
|
|
|
|
|
|
return Colors.blue;
|
|
|
|
|
|
case DecoderState.error:
|
|
|
|
|
|
return Colors.red;
|
|
|
|
|
|
case DecoderState.released:
|
|
|
|
|
|
return Colors.grey;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return Colors.black;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-04-23 10:37:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加错误帧绘制器
|
|
|
|
|
|
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; // 每次都重新绘制以产生动态效果
|
|
|
|
|
|
}
|
2025-04-21 10:56:28 +08:00
|
|
|
|
}
|