Files
flutterApp/lib/features/v2/local_camera/data/rtsp_playback_tuning.dart

339 lines
14 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 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 解码方式(对应原生 PlaybackTuning.decoderMode)。
///
/// Media3 不传 RenderersFactory 时 = `DefaultRenderersFactory` + `MediaCodecSelector.DEFAULT`,
/// 即**硬件优先**,而且硬解初始化失败**不会**自动回退软解(直接报错)。
enum RtspDecoderMode {
hardwarePreferred(0, '硬解', 'Media3 默认:硬件解码器优先'),
softwareOnly(1, '软解', '只用软件解码:排除硬解差异,功耗高、高分辨率可能跟不上'),
hardwareWithFallback(2, '硬解+回退', '硬件优先,初始化失败时回退软解(缺对应硬解的机型有用)');
const RtspDecoderMode(this.nativeValue, this.label, this.desc);
final int nativeValue;
final String label;
final String desc;
static RtspDecoderMode fromName(String? name) {
return RtspDecoderMode.values.firstWhere(
(m) => m.name == name,
orElse: () => hardwarePreferred,
);
}
/// 从原生回显的 int 还原(回显里没有 name,只有 nativeValue)。
static RtspDecoderMode fromNative(int? value) {
return RtspDecoderMode.values.firstWhere(
(m) => m.nativeValue == value,
orElse: () => hardwarePreferred,
);
}
}
/// 取哪个 ONVIF Profile —— 这是 App 侧唯一能"调码率"的抓手。
///
/// App 不改摄像头编码配置(那要 SetVideoEncoderConfiguration,会写设备),
/// 而是换一条已配好的流:子码流低分辨率低码率、主码流反之。
enum RtspProfileSelect {
smallest('子码流', '分辨率最小的 Profile(默认,省带宽)'),
largest('主码流', '分辨率最大的 Profile(验证是不是码率超出图传吞吐)'),
first('设备首个', 'GetProfiles 返回的第一个(很多机型=主码流)');
const RtspProfileSelect(this.label, this.desc);
final String label;
final String desc;
static RtspProfileSelect fromName(String? name) {
return RtspProfileSelect.values.firstWhere(
(p) => p.name == name,
orElse: () => smallest,
);
}
}
/// 直播播放调参(与本地覆盖包 packages/video_player_android 的 PlaybackTuning.java 对应)。
///
/// 用途:把「治花屏」和「治延迟」两个互相冲突的策略做成**运行时可改的选项**,
/// 现场 A/B 不需要重新编译。改动只对**下一次拉流**生效(ExoPlayer 创建时读取)。
///
/// 两个策略的取舍(源码依据见各字段注释):
/// - **花屏** ← 空口/图传丢包。UDP 不重传,坏帧直接解出马赛克;TCP 会重传把画面补全,
/// 代价是弱链路下卡顿、延迟上升。
/// - **延迟** ← ExoPlayer 稳态积压。Media3 `DefaultLoadControl` 的 Loader 在
/// `minBufferMs..maxBufferMs` 之间锯齿,平均积压约为 `(min+max)/2`;
/// `bufferForPlaybackMs=0` 只决定「起播不等缓冲」,不压稳态延迟。
class RtspPlaybackTuning {
const RtspPlaybackTuning({
this.forceRtpTcp = false,
this.minBufferMs = 500,
this.maxBufferMs = 3000,
this.bufferForPlaybackMs = 0,
this.bufferForPlaybackAfterRebufferMs = 0,
this.rtspTimeoutMs = 0,
this.targetBufferBytes = 0,
this.decoderMode = RtspDecoderMode.hardwarePreferred,
this.profileSelect = RtspProfileSelect.smallest,
});
/// RTSP 的 RTP 传输方式。
/// false = UDP(Media3 默认,延迟低,丢包即花屏);true = TCP interleaved(抗丢包,代价是卡)。
final bool forceRtpTcp;
/// Loader 重新恢复加载的水位(毫秒)。
/// 注意:Media3 内部有 `minBufferUs = max(minBufferUs, 500_000)` 的硬下限,
/// 填小于 500 的值**不会有任何效果**,UI 上要把这一点告诉用户。
final int minBufferMs;
/// Loader 停止加载的水位(毫秒)= 稳态积压上限,延迟最直接的可调项。
final int maxBufferMs;
/// 起播/暂停恢复所需缓冲(毫秒)。直播保持 0,首帧到达即渲染。
final int bufferForPlaybackMs;
/// rebuffer 后恢复播放所需缓冲(毫秒)。直播保持 0,避免每次卡完再攒一段。
final int bufferForPlaybackAfterRebufferMs;
/// RtspMediaSource 超时(毫秒)。0 = 用 Media3 默认值。
/// 它同时影响「UDP 长时间无数据后改用 TCP 重试」与「多久无数据判为流结束」,
/// 设太小会在弱链路上误判断流。
final int rtspTimeoutMs;
/// 字节上限 `targetBufferBytes`。0 = 不覆盖,用 Media3 默认(视频约 2MB)。
/// 加载器是「时间水位到了**或**字节数到了」就停:子码流下 2MB 等于十几秒、轮不到它;
/// 换主码流/高码率后 2MB 可能不到 1 秒就撞上 → 表现为频繁 rebuffer,需调大。
/// 注:这就是当年 media_kit 那个 32MB bufferSize 在 ExoPlayer 里的对应项。
final int targetBufferBytes;
/// 解码方式(硬解/软解/硬解失败回退软解)。
final RtspDecoderMode decoderMode;
/// 取主码流还是子码流(在 ONVIF 取流那一步生效,猜 URL 兜底路径不受它控制)。
final RtspProfileSelect profileSelect;
static const RtspPlaybackTuning defaults = RtspPlaybackTuning();
// ===== 预设档位(现场试验直接点,不用逐个填数字)=====
/// 均衡:TCP 补画面 + 中等水位。图传链路上推荐先从这里开始试。
static const RtspPlaybackTuning balanced = RtspPlaybackTuning(
forceRtpTcp: true,
maxBufferMs: 1500,
);
/// 抗花屏优先:TCP + 原水位。画面完整性优先,能接受卡顿。
static const RtspPlaybackTuning antiArtifact = RtspPlaybackTuning(
forceRtpTcp: true,
maxBufferMs: 3000,
);
/// 低延迟优先:UDP + 紧水位。链路质量好、要最跟手时用。
static const RtspPlaybackTuning lowLatency = RtspPlaybackTuning(
forceRtpTcp: false,
maxBufferMs: 1000,
);
/// 稳态积压估算(毫秒):Loader 锯齿的中点。
int get estimatedBacklogMs => (minBufferMs + maxBufferMs) ~/ 2;
/// 当前是否处于「Media3 原生默认行为」——用于 UI 标记未改动。
bool get isDefault =>
forceRtpTcp == defaults.forceRtpTcp &&
minBufferMs == defaults.minBufferMs &&
maxBufferMs == defaults.maxBufferMs &&
bufferForPlaybackMs == defaults.bufferForPlaybackMs &&
bufferForPlaybackAfterRebufferMs == defaults.bufferForPlaybackAfterRebufferMs &&
rtspTimeoutMs == defaults.rtspTimeoutMs &&
targetBufferBytes == defaults.targetBufferBytes &&
decoderMode == defaults.decoderMode &&
profileSelect == defaults.profileSelect;
/// 给页面显示的一行摘要(含策略含义,省得对着数字猜)。
///
/// targetBufferBytes 是字节上限,它和 maxBufferMs 哪个先到就按哪个停,
/// 所以**它一旦被改大,延迟上限会跟着抬高**——这个字段以前不在摘要里,
/// 导致“今天怎么变慢了”完全看不出来,现在只要不是默认值就显式标出来。
String get summary {
final String bytesPart = targetBufferBytes == defaults.targetBufferBytes
? ''
: ' · 字节上限${targetBufferBytes ~/ 1024}KB';
return '${forceRtpTcp ? 'TCP' : 'UDP'} · '
'水位 $minBufferMs~${maxBufferMs}ms(≈$estimatedBacklogMs) · '
'${profileSelect.label} · ${decoderMode.label}$bytesPart';
}
RtspPlaybackTuning copyWith({
bool? forceRtpTcp,
int? minBufferMs,
int? maxBufferMs,
int? bufferForPlaybackMs,
int? bufferForPlaybackAfterRebufferMs,
int? rtspTimeoutMs,
int? targetBufferBytes,
RtspDecoderMode? decoderMode,
RtspProfileSelect? profileSelect,
}) {
return RtspPlaybackTuning(
forceRtpTcp: forceRtpTcp ?? this.forceRtpTcp,
minBufferMs: minBufferMs ?? this.minBufferMs,
maxBufferMs: maxBufferMs ?? this.maxBufferMs,
bufferForPlaybackMs: bufferForPlaybackMs ?? this.bufferForPlaybackMs,
bufferForPlaybackAfterRebufferMs:
bufferForPlaybackAfterRebufferMs ?? this.bufferForPlaybackAfterRebufferMs,
rtspTimeoutMs: rtspTimeoutMs ?? this.rtspTimeoutMs,
targetBufferBytes: targetBufferBytes ?? this.targetBufferBytes,
decoderMode: decoderMode ?? this.decoderMode,
profileSelect: profileSelect ?? this.profileSelect,
);
}
/// 下发给原生侧的参数。`max < min` 这类非法组合在这里先夹住:
/// Media3 构造函数对 maxBufferMs >= minBufferMs 有断言,越界会直接抛异常表现为首帧黑屏。
/// 注意:profileSelect 不出现在这里——它作用于 ONVIF 取流(Dart 侧),不是 ExoPlayer 参数。
Map<String, Object> toChannelArgs() {
final int min = minBufferMs < 500 ? 500 : minBufferMs; // Media3 硬下限
final int max = maxBufferMs < min ? min : maxBufferMs;
return <String, Object>{
'forceRtpTcp': forceRtpTcp,
'minBufferMs': min,
'maxBufferMs': max,
'bufferForPlaybackMs': bufferForPlaybackMs,
'bufferForPlaybackAfterRebufferMs': bufferForPlaybackAfterRebufferMs,
'rtspTimeoutMs': rtspTimeoutMs,
'targetBufferBytes': targetBufferBytes < 0 ? 0 : targetBufferBytes,
'decoderMode': decoderMode.nativeValue,
};
}
/// 持久化用:枚举存 name(存 index 会因枚举重排而错位)。
Map<String, Object> toJson() => <String, Object>{
...toChannelArgs(),
'profileSelect': profileSelect.name,
'decoderModeName': decoderMode.name,
};
factory RtspPlaybackTuning.fromMap(Map<String, Object?> m) {
return RtspPlaybackTuning(
forceRtpTcp: (m['forceRtpTcp'] as bool?) ?? defaults.forceRtpTcp,
minBufferMs: (m['minBufferMs'] as num?)?.toInt() ?? defaults.minBufferMs,
maxBufferMs: (m['maxBufferMs'] as num?)?.toInt() ?? defaults.maxBufferMs,
bufferForPlaybackMs:
(m['bufferForPlaybackMs'] as num?)?.toInt() ?? defaults.bufferForPlaybackMs,
bufferForPlaybackAfterRebufferMs:
(m['bufferForPlaybackAfterRebufferMs'] as num?)?.toInt() ??
defaults.bufferForPlaybackAfterRebufferMs,
rtspTimeoutMs: (m['rtspTimeoutMs'] as num?)?.toInt() ?? defaults.rtspTimeoutMs,
targetBufferBytes: (m['targetBufferBytes'] as num?)?.toInt() ?? defaults.targetBufferBytes,
// 缓存里存 name,原生回显里只有 int:两种来源都要能解。
decoderMode: m['decoderModeName'] != null
? RtspDecoderMode.fromName(m['decoderModeName'] as String?)
: RtspDecoderMode.fromNative((m['decoderMode'] as num?)?.toInt()),
profileSelect: RtspProfileSelect.fromName(m['profileSelect'] as String?),
);
}
// ===== 下发到原生插件 =====
static const MethodChannel _channel =
MethodChannel('plugins.flutter.dev/video_player_android/tuning');
/// 把当前配置下发给插件。**必须在 `VideoPlayerController.initialize()` 之前调用**,
/// 因为 LoadControl / RtspMediaSource.Factory 都是在创建播放器那一刻读值的。
Future<void> apply() async {
try {
final Map<Object?, Object?>? echoed =
await _channel.invokeMethod<Map<Object?, Object?>>('set', toChannelArgs());
debugPrint(
'🛠 [调参] 已下发 $summary → 原生回显 ${echoed?['maxBufferMs']}ms 水位 / '
'TCP=${echoed?['forceRtpTcp']}',
);
} on PlatformException catch (e) {
debugPrint('🛠 [调参] 下发失败:${e.code} ${e.message}');
} on MissingPluginException {
// 非 Android 平台或未使用本地覆盖包(例如误装了 pub.dev 的 video_player_android)。
debugPrint('🛠 [调参] 未找到调参通道:确认本地覆盖包已生效(dependency_overrides)');
}
}
/// 读回原生侧当前真正生效的值,用于「改了参数到底有没有进插件」的定性验证。
static Future<RtspPlaybackTuning?> readBack() async {
try {
final Map<Object?, Object?>? m =
await _channel.invokeMethod<Map<Object?, Object?>>('get');
if (m == null) return null;
return RtspPlaybackTuning.fromMap(
m.map((k, v) => MapEntry<String, Object?>('$k', v)),
);
} catch (_) {
return null;
}
}
// ===== 持久化(跨启动保留,方便反复折腾对比)=====
static const String _prefsKey = 'local_camera_playback_tuning';
Future<void> save() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, jsonEncode(toJson()));
} catch (_) {}
}
static Future<RtspPlaybackTuning> load() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_prefsKey);
if (raw == null || raw.isEmpty) return defaults;
final m = (jsonDecode(raw) as Map).cast<String, Object?>();
return RtspPlaybackTuning.fromMap(m);
} catch (_) {
return defaults;
}
}
Future<void> clearSaved() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKey);
} catch (_) {}
}
/// 值相等:State 的 Equatable 比较、预设档位的选中高亮都依赖它,
/// 否则从缓存读回来的同参配置会被当成“另一个对象”而多刷一次屏。
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RtspPlaybackTuning &&
other.forceRtpTcp == forceRtpTcp &&
other.minBufferMs == minBufferMs &&
other.maxBufferMs == maxBufferMs &&
other.bufferForPlaybackMs == bufferForPlaybackMs &&
other.bufferForPlaybackAfterRebufferMs == bufferForPlaybackAfterRebufferMs &&
other.rtspTimeoutMs == rtspTimeoutMs &&
other.targetBufferBytes == targetBufferBytes &&
other.decoderMode == decoderMode &&
other.profileSelect == profileSelect;
@override
int get hashCode => Object.hash(
forceRtpTcp,
minBufferMs,
maxBufferMs,
bufferForPlaybackMs,
bufferForPlaybackAfterRebufferMs,
rtspTimeoutMs,
targetBufferBytes,
decoderMode,
profileSelect,
);
@override
String toString() => summary;
}