339 lines
14 KiB
Dart
339 lines
14 KiB
Dart
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;
|
||
}
|