1334 lines
48 KiB
Dart
Executable File
1334 lines
48 KiB
Dart
Executable File
import 'dart:async';
|
||
import 'dart:typed_data';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart';
|
||
import 'package:starcloud/entity/star_cloud_lock_list.dart';
|
||
import 'package:flutter_sdk/starcloud_flutter_sdk.dart';
|
||
import 'package:video_decode_plugin/video_decode_plugin.dart';
|
||
import '../l10n.dart';
|
||
import '../global.dart';
|
||
import '../widgets/common_widgets.dart';
|
||
import '../services/cat_eye_monitor_service.dart';
|
||
|
||
class VideoTalkTab extends StatefulWidget {
|
||
const VideoTalkTab({super.key});
|
||
|
||
@override
|
||
State<VideoTalkTab> createState() => _VideoTalkTabState();
|
||
}
|
||
|
||
class _VideoTalkTabState extends State<VideoTalkTab> {
|
||
|
||
static const _maxVideoReorderDistance = 3;
|
||
static const _videoReorderWait = Duration(milliseconds: 15);
|
||
|
||
static const TalkOptions _monitorOptions = TalkOptions(
|
||
preferredVideoTypes: [VideoTypeE.H264],
|
||
preferredAudioTypes: [AudioTypeE.G711],
|
||
);
|
||
|
||
final List<String> _log = [];
|
||
|
||
IncomingCallEvent? _call;
|
||
TalkStatus _status = TalkStatus.idle;
|
||
String? _activeLockPeerId;
|
||
|
||
// 视频解码器
|
||
int? _textureId;
|
||
bool _decoderReady = false;
|
||
Future<bool>? _decoderInitFuture;
|
||
bool _frameRendered = false;
|
||
bool _remoteUnlocking = false;
|
||
int? _videoLockId;
|
||
int? _videoLockCloudUid;
|
||
int _videoCount = 0;
|
||
int _videoWidth = 640;
|
||
int _videoHeight = 480;
|
||
int _videoQuarterTurns = 3;
|
||
int? _framePtsBaseWallTimeMs;
|
||
int _lastFramePtsMs = -1;
|
||
Timer? _videoProcessTimer;
|
||
final List<_QueuedVideoFrame> _videoBuffer = <_QueuedVideoFrame>[];
|
||
_QueuedVideoFrame? _pendingVideoFrame;
|
||
int _videoSendGeneration = 0;
|
||
bool _sendingVideoFrame = false;
|
||
int _highestVideoFrameSeq = -1;
|
||
int? _lastDecodedIFrameSeq;
|
||
bool _waitingForIFrame = true;
|
||
bool _isCallMode = false;
|
||
bool _preparingMonitor = false;
|
||
Timer? _videoFrameTimeoutTimer;
|
||
bool _audioReady = false;
|
||
Future<void>? _audioInitFuture;
|
||
Future<void> _audioQueue = Future<void>.value();
|
||
int _audioPacketCount = 0;
|
||
int _audioGeneration = 0;
|
||
|
||
StreamSubscription? _callSub;
|
||
StreamSubscription? _statusSub;
|
||
StreamSubscription? _dataSub;
|
||
StreamSubscription? _expectSub;
|
||
Timer? _peerIdPollTimer;
|
||
bool _resolvingPeerId = false;
|
||
|
||
void _log_(String msg) {
|
||
final ts = DateTime.now().toIso8601String().substring(11, 19);
|
||
if (mounted) setState(() => _log.add('[$ts] $msg'));
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initDeviceAndListeners();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_callSub?.cancel();
|
||
_statusSub?.cancel();
|
||
_dataSub?.cancel();
|
||
_expectSub?.cancel();
|
||
_peerIdPollTimer?.cancel();
|
||
_videoProcessTimer?.cancel();
|
||
_videoFrameTimeoutTimer?.cancel();
|
||
_videoBuffer.clear();
|
||
unawaited(_stopAudio());
|
||
VideoDecodePlugin.setOnMissingSpsPpsListener(() {});
|
||
_releaseDecoder();
|
||
pairedLock.removeListener(_onPairedLockChanged);
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _initDeviceAndListeners() async {
|
||
// 监听对讲事件。服务在 WiFi 配网页的第 1 步初始化。
|
||
_callSub = StarCloudClient.instance.talk.onIncomingCall.listen((e) {
|
||
_log_('${S.incomingCall}: ${e.callerName}');
|
||
print('${S.incomingCall}: ${e.callerName}');
|
||
if (mounted)
|
||
setState(() {
|
||
_call = e;
|
||
_status = TalkStatus.incomingCall;
|
||
_activeLockPeerId = e.lockPeerId;
|
||
_videoLockId = pairedLock.value?.lockId;
|
||
_videoLockCloudUid = pairedLock.value?.cloudUid;
|
||
});
|
||
});
|
||
|
||
_statusSub = StarCloudClient.instance.talk.onTalkStatus.listen((e) {
|
||
_log_('Talk status: ${e.status}');
|
||
print('Talk status: ${e.status}, lockPeerId=${e.lockPeerId}');
|
||
if (mounted) {
|
||
setState(() {
|
||
_status = e.status;
|
||
if (e.status == TalkStatus.inCall) {
|
||
_call = null;
|
||
}
|
||
if (e.status == TalkStatus.timeout) {
|
||
_log_(S.isEn
|
||
? 'Monitor timed out. Check if lock is online and connected to WiFi.'
|
||
: '监控超时,请确认锁已联网并在线。');
|
||
} else if (e.status == TalkStatus.rejected) {
|
||
_log_(S.isEn ? 'Monitor rejected by lock.' : '监控被锁端拒绝。');
|
||
} else if (e.status == TalkStatus.hangup) {
|
||
_log_(S.isEn ? 'Call ended.' : '通话已结束。');
|
||
} else if (e.status == TalkStatus.inCall) {
|
||
_log_(S.isEn
|
||
? 'Monitor connected, waiting for video...'
|
||
: '监控已连接,等待视频数据...');
|
||
_startVideoFrameTimeout();
|
||
}
|
||
if (e.status == TalkStatus.idle ||
|
||
e.status == TalkStatus.hangup ||
|
||
e.status == TalkStatus.timeout ||
|
||
e.status == TalkStatus.rejected) {
|
||
if (e.status == TalkStatus.timeout ||
|
||
e.status == TalkStatus.rejected) {
|
||
final paired = pairedLock.value;
|
||
_videoLockId = paired?.lockId;
|
||
_videoLockCloudUid = paired?.cloudUid;
|
||
}
|
||
_call = null;
|
||
_activeLockPeerId = null;
|
||
_cancelVideoFrameTimeout();
|
||
unawaited(_stopAudio());
|
||
_releaseDecoder();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
_dataSub = StarCloudClient.instance.talk.onTalkData.listen((e) {
|
||
if (!mounted) return;
|
||
if (e.isAudio) {
|
||
_audioPacketCount++;
|
||
if (_audioPacketCount == 1) {
|
||
final audioSize = e.content.length;
|
||
_log_('First G711 audio packet received (size=`$audioSize`)');
|
||
}
|
||
final generation = _audioGeneration;
|
||
_audioQueue = _audioQueue
|
||
.then((_) => _playAudioPacket(e.content, generation))
|
||
.catchError((Object error, StackTrace stack) {
|
||
final errorText = error.toString();
|
||
_log_('Audio playback error: `$errorText`');
|
||
});
|
||
return;
|
||
} else {
|
||
_videoCount++;
|
||
_cancelVideoFrameTimeout();
|
||
if (_videoCount == 1) {
|
||
final preview = e.content
|
||
.take(12)
|
||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||
.join(' ');
|
||
_log_(
|
||
'First video frame received (type=${e.contentType}, size=${e.content.length})');
|
||
print(
|
||
'[VideoTalk] first frame from=${e.fromPeerId} type=${e.contentType} size=${e.content.length} head=$preview');
|
||
} else if (_videoCount % 120 == 0) {
|
||
print(
|
||
'[VideoTalk] video frames=$_videoCount, latestSize=${e.content.length}');
|
||
}
|
||
_feedVideoFrame(e);
|
||
}
|
||
});
|
||
|
||
_expectSub = StarCloudClient.instance.talk.onTalkExpectResp.listen((e) {
|
||
_log_('TalkExpect response from ${e.fromPeerId}: '
|
||
'${e.width}x${e.height}, rotate=${e.rotate}, '
|
||
'video=${e.videoType.name}, audio=${e.audioType.name}');
|
||
print('[VideoTalk] TalkExpect response from ${e.fromPeerId}: '
|
||
'${e.width}x${e.height}, rotate=${e.rotate}, '
|
||
'video=${e.videoType.name}, audio=${e.audioType.name}');
|
||
if (e.videoType != VideoTypeE.H264 || e.width <= 0 || e.height <= 0) {
|
||
_log_('TalkExpect did not negotiate usable H264 video.');
|
||
return;
|
||
}
|
||
final quarterTurns = ((e.rotate % 360) + 360) % 360 ~/ 90;
|
||
final sizeChanged = e.width != _videoWidth || e.height != _videoHeight;
|
||
_videoWidth = e.width;
|
||
_videoHeight = e.height;
|
||
_videoQuarterTurns = quarterTurns;
|
||
if (mounted) setState(() {});
|
||
if (sizeChanged) {
|
||
_log_('Video format negotiated: ${e.width}x${e.height}');
|
||
print('[VideoTalk] video format negotiated: ${e.width}x${e.height}');
|
||
unawaited(_recreateDecoderForResolution());
|
||
}
|
||
});
|
||
|
||
pairedLock.addListener(_onPairedLockChanged);
|
||
_onPairedLockChanged();
|
||
}
|
||
|
||
// ==================== 音频播放 ====================
|
||
|
||
Future<void> _ensureAudioReady() {
|
||
if (_audioReady) return Future<void>.value();
|
||
final pending = _audioInitFuture;
|
||
if (pending != null) return pending;
|
||
|
||
final future = _doInitAudio();
|
||
_audioInitFuture = future;
|
||
future.whenComplete(() {
|
||
if (identical(_audioInitFuture, future)) {
|
||
_audioInitFuture = null;
|
||
}
|
||
});
|
||
return future;
|
||
}
|
||
|
||
Future<void> _doInitAudio() async {
|
||
if (_audioReady) return;
|
||
await FlutterPcmSound.setLogLevel(LogLevel.error);
|
||
await FlutterPcmSound.setup(sampleRate: 8000, channelCount: 1);
|
||
await FlutterPcmSound.setFeedThreshold(1024);
|
||
_audioReady = true;
|
||
_log_('G711 audio player ready (8000 Hz, mono).');
|
||
}
|
||
|
||
Future<void> _playAudioPacket(Uint8List g711Data, int generation) async {
|
||
if (g711Data.isEmpty) return;
|
||
if (generation != _audioGeneration) return;
|
||
await _ensureAudioReady();
|
||
if (!_audioReady || generation != _audioGeneration) return;
|
||
final pcm = <int>[
|
||
for (final sample in g711Data) _decodeG711ALaw(sample),
|
||
];
|
||
if (pcm.isEmpty) return;
|
||
if (generation != _audioGeneration) return;
|
||
await FlutterPcmSound.feed(PcmArrayInt16.fromList(pcm));
|
||
await FlutterPcmSound.play();
|
||
}
|
||
|
||
int _decodeG711ALaw(int value) {
|
||
final aLaw = (value & 0xff) ^ 0x55;
|
||
var sample = (aLaw & 0x0f) << 4;
|
||
final segment = (aLaw & 0x70) >> 4;
|
||
if (segment == 0) {
|
||
sample += 8;
|
||
} else if (segment == 1) {
|
||
sample += 0x108;
|
||
} else {
|
||
sample += 0x108;
|
||
sample <<= segment - 1;
|
||
}
|
||
final signed = (aLaw & 0x80) != 0 ? sample : -sample;
|
||
return signed.clamp(-32768, 32767);
|
||
}
|
||
|
||
Future<void> _stopAudio() async {
|
||
_audioGeneration++;
|
||
_audioQueue = Future<void>.value();
|
||
final pendingInit = _audioInitFuture;
|
||
if (pendingInit != null) {
|
||
try {
|
||
await pendingInit;
|
||
} catch (_) {}
|
||
}
|
||
if (!_audioReady) return;
|
||
try {
|
||
await FlutterPcmSound.stop();
|
||
await FlutterPcmSound.clear();
|
||
await FlutterPcmSound.release();
|
||
} catch (_) {}
|
||
_audioReady = false;
|
||
_audioInitFuture = null;
|
||
_audioPacketCount = 0;
|
||
}
|
||
|
||
// ==================== 视频解码器 ====================
|
||
|
||
Future<bool> _initDecoder() {
|
||
if (_decoderReady) return Future<bool>.value(true);
|
||
final pending = _decoderInitFuture;
|
||
if (pending != null) return pending;
|
||
|
||
final future = _doInitDecoder();
|
||
_decoderInitFuture = future;
|
||
future.whenComplete(() {
|
||
if (identical(_decoderInitFuture, future)) {
|
||
_decoderInitFuture = null;
|
||
}
|
||
});
|
||
return future;
|
||
}
|
||
|
||
Future<bool> _doInitDecoder() async {
|
||
if (_decoderReady) return true;
|
||
try {
|
||
// video_decode_plugin 的 Android 端默认使用 FFmpeg;这里显式
|
||
// 保持同一后端,避免插件默认值变化时两端走出不同的解码流程。
|
||
final engineState = await VideoDecodePlugin.setDecodeEngine(
|
||
VideoDecodePlugin.engineFfmpeg,
|
||
applyImmediately: false,
|
||
);
|
||
final activeEngine = engineState['activeEngine']?.toString();
|
||
_log_('Video decoder engine: $activeEngine');
|
||
if (activeEngine != VideoDecodePlugin.engineFfmpeg) {
|
||
_log_('FFmpeg video decoder is unavailable; monitor was not started.');
|
||
return false;
|
||
}
|
||
final config = VideoDecoderConfig(
|
||
width: _videoWidth,
|
||
height: _videoHeight,
|
||
codecType: 'h264',
|
||
);
|
||
final tid = await VideoDecodePlugin.initDecoder(config);
|
||
if (tid == null) {
|
||
_log_('Video decoder init failed: null textureId');
|
||
print('Video decoder init failed: null textureId');
|
||
return false;
|
||
}
|
||
// initDecoder 后再次确认后端,保证整个会话都与参考项目一致。
|
||
final initializedEngine =
|
||
(await VideoDecodePlugin.getDecodeEngine())['activeEngine']
|
||
?.toString();
|
||
if (initializedEngine != VideoDecodePlugin.engineFfmpeg) {
|
||
await VideoDecodePlugin.releaseDecoder();
|
||
_log_(
|
||
'FFmpeg video decoder failed to start (active=$initializedEngine).');
|
||
return false;
|
||
}
|
||
|
||
_textureId = tid;
|
||
_decoderReady = true;
|
||
_frameRendered = false;
|
||
_videoCount = 0;
|
||
_framePtsBaseWallTimeMs = null;
|
||
_lastFramePtsMs = -1;
|
||
_highestVideoFrameSeq = -1;
|
||
_lastDecodedIFrameSeq = null;
|
||
_waitingForIFrame = true;
|
||
_videoProcessTimer?.cancel();
|
||
_videoProcessTimer = Timer.periodic(
|
||
const Duration(milliseconds: 5),
|
||
(_) => _processNextVideoFrame(),
|
||
);
|
||
VideoDecodePlugin.setOnFrameRenderedListener((textureId) {
|
||
_frameRendered = true;
|
||
_log_('First decoded video frame rendered (textureId=$textureId)');
|
||
if (mounted) setState(() {});
|
||
});
|
||
VideoDecodePlugin.setOnMissingSpsPpsListener(() {
|
||
if (!mounted || _status != TalkStatus.inCall) return;
|
||
_log_(S.isEn
|
||
? 'Missing H264 SPS/PPS, requesting a key frame...'
|
||
: '缺少 H264 SPS/PPS,正在重新请求关键帧...');
|
||
StarCloudClient.instance.talk.renegotiate(
|
||
options: _isCallMode ? TalkOptions.defaultOptions : _monitorOptions,
|
||
);
|
||
});
|
||
_log_('Video decoder ready (textureId=$tid)');
|
||
print('Video decoder ready (textureId=$tid)');
|
||
if (mounted) setState(() {});
|
||
return true;
|
||
} catch (e) {
|
||
_log_('Video decoder init error: $e');
|
||
print('Video decoder init error: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<void> _releaseDecoder() async {
|
||
if (!_decoderReady) return;
|
||
try {
|
||
await VideoDecodePlugin.releaseDecoder();
|
||
} catch (_) {}
|
||
_textureId = null;
|
||
_decoderReady = false;
|
||
_frameRendered = false;
|
||
_decoderInitFuture = null;
|
||
_videoSendGeneration++;
|
||
_pendingVideoFrame = null;
|
||
_sendingVideoFrame = false;
|
||
_videoProcessTimer?.cancel();
|
||
_videoProcessTimer = null;
|
||
_videoFrameTimeoutTimer?.cancel();
|
||
_videoFrameTimeoutTimer = null;
|
||
_videoBuffer.clear();
|
||
_highestVideoFrameSeq = -1;
|
||
_lastDecodedIFrameSeq = null;
|
||
_waitingForIFrame = true;
|
||
_videoCount = 0;
|
||
_framePtsBaseWallTimeMs = null;
|
||
_lastFramePtsMs = -1;
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
Future<void> _recreateDecoderForResolution() async {
|
||
await _releaseDecoder();
|
||
if (mounted) await _initDecoder();
|
||
}
|
||
|
||
void _startVideoFrameTimeout() {
|
||
_cancelVideoFrameTimeout();
|
||
_videoFrameTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
||
if (_videoCount == 0 && mounted) {
|
||
_log_(S.isEn
|
||
? 'No video frames received within 10s. Check camera and video settings.'
|
||
: '10秒内未收到视频帧,请检查摄像头及视频设置。');
|
||
print('Video frame timeout: no frames received in 10s');
|
||
}
|
||
});
|
||
}
|
||
|
||
void _cancelVideoFrameTimeout() {
|
||
_videoFrameTimeoutTimer?.cancel();
|
||
_videoFrameTimeoutTimer = null;
|
||
}
|
||
|
||
/// 将 TalkDataEvent 中的视频帧送入解码器
|
||
void _feedVideoFrame(TalkDataEvent e) {
|
||
if (!_decoderReady || _textureId == null) return;
|
||
try {
|
||
final Uint8List frameData = e.content;
|
||
if (e.isJpeg) {
|
||
return;
|
||
}
|
||
if (!e.isH264) return;
|
||
if (frameData.isEmpty) return;
|
||
|
||
final metadata = e.h264Frame;
|
||
final bool hasValidMetadata = metadata != null &&
|
||
(metadata.frameType.value == 1 || metadata.frameType.value == 2);
|
||
if (!hasValidMetadata) {
|
||
_log_('Invalid H264 metadata; dropping frame');
|
||
print('[VideoTalk] invalid H264 metadata, dropping frame');
|
||
return;
|
||
}
|
||
|
||
final bool isIFrame = metadata.frameType.value == 1;
|
||
|
||
final int frameType = isIFrame ? 0 : 1;
|
||
|
||
final int frameSeq = metadata.frameSeq;
|
||
final int frameSeqI = metadata.frameSeqI;
|
||
|
||
// 对齐参考项目:检测到锁端视频流重启(I 帧序号大幅回退)时,
|
||
// 清除旧 GOP 的待处理帧,立即从新的 I 帧恢复。
|
||
if (isIFrame &&
|
||
_highestVideoFrameSeq >= 0 &&
|
||
frameSeq < _highestVideoFrameSeq) {
|
||
final rolloverThreshold =
|
||
_getFrameSeqRolloverThreshold(_highestVideoFrameSeq);
|
||
if (_highestVideoFrameSeq - frameSeq > rolloverThreshold) {
|
||
_log_(
|
||
'Video stream reset detected; resuming from I-frame $frameSeq.');
|
||
_videoBuffer.clear();
|
||
_pendingVideoFrame = null;
|
||
_lastDecodedIFrameSeq = null;
|
||
_waitingForIFrame = false;
|
||
_highestVideoFrameSeq = frameSeq;
|
||
} else {
|
||
return;
|
||
}
|
||
} else if (_highestVideoFrameSeq >= 0 &&
|
||
frameSeq <= _highestVideoFrameSeq) {
|
||
if (_videoBuffer.any((frame) => frame.frameSeq == frameSeq)) {
|
||
return;
|
||
}
|
||
if (_highestVideoFrameSeq - frameSeq > _maxVideoReorderDistance) {
|
||
return;
|
||
}
|
||
}
|
||
if (frameSeq > _highestVideoFrameSeq) {
|
||
_highestVideoFrameSeq = frameSeq;
|
||
}
|
||
|
||
if (isIFrame) {
|
||
_waitingForIFrame = false;
|
||
_videoBuffer.removeWhere((f) => f.frameType == 1);
|
||
} else if (_waitingForIFrame ||
|
||
(_lastDecodedIFrameSeq != null &&
|
||
frameSeqI != 0 &&
|
||
frameSeqI != _lastDecodedIFrameSeq)) {
|
||
if (_lastDecodedIFrameSeq != null && frameSeqI != 0) {
|
||
_waitingForIFrame = true;
|
||
_videoBuffer.removeWhere((f) => f.frameType == 1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
_videoBuffer.add(_QueuedVideoFrame(
|
||
frameData: frameData,
|
||
frameType: frameType,
|
||
frameSeq: frameSeq,
|
||
frameSeqI: frameSeqI,
|
||
arrivalWallTimeMs: DateTime.now().millisecondsSinceEpoch,
|
||
));
|
||
_videoBuffer.sort((a, b) => a.frameSeq.compareTo(b.frameSeq));
|
||
while (_videoBuffer.length > 6) {
|
||
final index = _videoBuffer.indexWhere((f) => f.frameType == 1);
|
||
_videoBuffer.removeAt(index >= 0 ? index : 0);
|
||
}
|
||
} catch (err) {
|
||
_log_('Frame feed error: $err');
|
||
print('Frame feed error: $err');
|
||
}
|
||
}
|
||
|
||
void _processNextVideoFrame() {
|
||
if (_videoBuffer.isEmpty || !_decoderReady) return;
|
||
var index = _videoBuffer.indexWhere((f) => f.frameType == 0);
|
||
if (index >= 0) {
|
||
final keyFrameSeq = _videoBuffer[index].frameSeq;
|
||
_videoBuffer
|
||
.removeWhere((f) => f.frameType == 1 && f.frameSeq < keyFrameSeq);
|
||
index = _videoBuffer
|
||
.indexWhere((f) => f.frameType == 0 && f.frameSeq == keyFrameSeq);
|
||
final frame = _videoBuffer[index];
|
||
if (_lastDecodedIFrameSeq != null &&
|
||
_shouldWaitForReorderedFrame(frame)) {
|
||
return;
|
||
}
|
||
} else if (_lastDecodedIFrameSeq != null) {
|
||
_videoBuffer.removeWhere((f) =>
|
||
f.frameType == 1 &&
|
||
f.frameSeqI != 0 &&
|
||
f.frameSeqI != _lastDecodedIFrameSeq);
|
||
index = _videoBuffer.indexWhere((f) => f.frameType == 1);
|
||
if (index >= 0 && _shouldWaitForReorderedFrame(_videoBuffer[index])) {
|
||
return;
|
||
}
|
||
}
|
||
if (index < 0) {
|
||
return;
|
||
}
|
||
final frame = _videoBuffer.removeAt(index);
|
||
_submitVideoFrame(frame);
|
||
}
|
||
|
||
/// 参考项目 VideoPipelineV2:解码器忙碌时只保留最新帧,关键帧优先。
|
||
/// 这样不会让过期 P 帧持续堆积,画面能尽快追上实时流。
|
||
void _submitVideoFrame(_QueuedVideoFrame frame) {
|
||
if (frame.frameType == 0) {
|
||
_lastDecodedIFrameSeq = frame.frameSeq;
|
||
}
|
||
if (!_sendingVideoFrame) {
|
||
_sendingVideoFrame = true;
|
||
unawaited(_drainVideoFrames(frame, _videoSendGeneration));
|
||
return;
|
||
}
|
||
|
||
final pending = _pendingVideoFrame;
|
||
if (frame.frameType == 0 || pending == null || pending.frameType != 0) {
|
||
_pendingVideoFrame = frame;
|
||
}
|
||
}
|
||
|
||
Future<void> _drainVideoFrames(
|
||
_QueuedVideoFrame firstFrame,
|
||
int generation,
|
||
) async {
|
||
var frame = firstFrame;
|
||
while (_decoderReady && generation == _videoSendGeneration) {
|
||
try {
|
||
await VideoDecodePlugin.sendFrame(
|
||
frameData: frame.frameData,
|
||
frameType: frame.frameType,
|
||
timestamp: _nextFrameTimestamp(frame.arrivalWallTimeMs),
|
||
ingressWallTimeMs: frame.arrivalWallTimeMs,
|
||
frameSeq: frame.frameSeq,
|
||
refIFrameSeq: frame.frameSeqI,
|
||
// TalkDataH264Frame.frameData 已经是完整 Annex-B 数据。
|
||
// 原生解码器会自行缓存 SPS/PPS 并处理 I/P NALU;这里不能再次
|
||
// 在 Dart 层拆分 I 帧,否则会丢失设备发送的有效切片格式。
|
||
splitNalFromIFrame: false,
|
||
);
|
||
} catch (err) {
|
||
_log_('Frame decode error: $err');
|
||
}
|
||
|
||
if (!_decoderReady || generation != _videoSendGeneration) {
|
||
return;
|
||
}
|
||
|
||
final nextFrame = _pendingVideoFrame;
|
||
_pendingVideoFrame = null;
|
||
if (nextFrame == null) {
|
||
_sendingVideoFrame = false;
|
||
return;
|
||
}
|
||
frame = nextFrame;
|
||
}
|
||
if (generation != _videoSendGeneration) {
|
||
return;
|
||
}
|
||
_pendingVideoFrame = null;
|
||
_sendingVideoFrame = false;
|
||
}
|
||
|
||
int _nextFrameTimestamp(int arrivalWallTimeMs) {
|
||
_framePtsBaseWallTimeMs ??= arrivalWallTimeMs;
|
||
final candidate = arrivalWallTimeMs - _framePtsBaseWallTimeMs!;
|
||
final timestamp =
|
||
candidate > _lastFramePtsMs ? candidate : _lastFramePtsMs + 1;
|
||
_lastFramePtsMs = timestamp;
|
||
return timestamp;
|
||
}
|
||
|
||
bool _shouldWaitForReorderedFrame(_QueuedVideoFrame frame) {
|
||
return _highestVideoFrameSeq - frame.frameSeq < _maxVideoReorderDistance &&
|
||
DateTime.now().millisecondsSinceEpoch - frame.arrivalWallTimeMs <
|
||
_videoReorderWait.inMilliseconds;
|
||
}
|
||
|
||
int _getFrameSeqRolloverThreshold(int lastSeq) {
|
||
return lastSeq > 2000 ? 1000 : (lastSeq / 2).round();
|
||
}
|
||
|
||
// ==================== PeerID 管理 ====================
|
||
|
||
void _onPairedLockChanged() {
|
||
final paired = pairedLock.value;
|
||
if (paired != null && _activeLockPeerId == null) {
|
||
_videoLockId = paired.lockId;
|
||
_videoLockCloudUid = paired.cloudUid;
|
||
}
|
||
_startPeerIdPolling();
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
/// 锁版 peerId 为空时定时轮询云端(每 5s),出现即回填并停止 —— 锁上线监控
|
||
void _startPeerIdPolling() {
|
||
_peerIdPollTimer?.cancel();
|
||
final paired = pairedLock.value;
|
||
if (paired == null) return;
|
||
if ((paired.peerId ?? '').trim().isNotEmpty) return;
|
||
_peerIdPollTimer = Timer.periodic(
|
||
const Duration(seconds: 5),
|
||
(_) => _pollLockPeerId(),
|
||
);
|
||
}
|
||
|
||
Future<void> _pollLockPeerId() async {
|
||
final current = pairedLock.value;
|
||
if (current == null) {
|
||
_peerIdPollTimer?.cancel();
|
||
return;
|
||
}
|
||
if ((current.peerId ?? '').trim().isNotEmpty) {
|
||
_peerIdPollTimer?.cancel();
|
||
return;
|
||
}
|
||
if (_resolvingPeerId) return;
|
||
_resolvingPeerId = true;
|
||
try {
|
||
final fresh = await StarCloudClient.instance.device
|
||
.getDevice(lockId: current.lockId);
|
||
final freshPeerId = (fresh.peerId ?? '').trim();
|
||
if (freshPeerId.isNotEmpty) {
|
||
pairedLock.value = fresh;
|
||
_log_('Lock PeerID detected (auto-poll): $freshPeerId');
|
||
print('Lock PeerID detected (auto-poll): $freshPeerId');
|
||
_peerIdPollTimer?.cancel();
|
||
}
|
||
} catch (_) {
|
||
// 锁可能尚未上报,下个周期重试
|
||
} finally {
|
||
_resolvingPeerId = false;
|
||
}
|
||
}
|
||
|
||
Future<String> _resolveLockPeerId() async {
|
||
final paired = pairedLock.value;
|
||
if (paired == null) {
|
||
_log_(S.isEn ? 'No paired lock. Please pair first.' : '未配对锁,请先在设备页配对。');
|
||
print(S.isEn ? 'No paired lock. Please pair first.' : '未配对锁,请先在设备页配对。');
|
||
return '';
|
||
}
|
||
|
||
if (_resolvingPeerId) return '';
|
||
_resolvingPeerId = true;
|
||
try {
|
||
final fresh = await StarCloudClient.instance.device
|
||
.getDevice(lockId: paired.lockId);
|
||
final freshPeerId = (fresh.peerId ?? '').trim();
|
||
final cachedPeerId = (paired.peerId ?? '').trim();
|
||
final online = fresh.isOnline == true;
|
||
_log_('Lock status: lockId=${paired.lockId}, cloudOnline=$online, '
|
||
'peerId=${freshPeerId.isEmpty ? "<empty>" : freshPeerId}');
|
||
print('[VideoTalk] Lock status: lockId=${paired.lockId}, '
|
||
'cloudOnline=$online, peerId=${freshPeerId.isEmpty ? "<empty>" : freshPeerId}');
|
||
|
||
if (freshPeerId.isNotEmpty) {
|
||
pairedLock.value = fresh;
|
||
if (!online) {
|
||
_log_(S.isEn
|
||
? 'Cloud status is offline; trying the monitor request with the latest lock PeerID.'
|
||
: '云端状态显示离线,但锁已有最新 PeerID,继续尝试监控请求。');
|
||
}
|
||
return freshPeerId;
|
||
}
|
||
|
||
if (cachedPeerId.isNotEmpty) {
|
||
if (!online) {
|
||
_log_(S.isEn
|
||
? 'Cloud status is offline; trying the cached lock PeerID.'
|
||
: '云端状态显示离线,使用已缓存的锁 PeerID 尝试监控请求。');
|
||
}
|
||
return cachedPeerId;
|
||
}
|
||
_log_(S.isEn
|
||
? 'Lock PeerID is empty. Ensure lock is online and WiFi configured.'
|
||
: '锁版PeerID为空,请确认锁已联网并完成WiFi配网。');
|
||
print(S.isEn
|
||
? 'Lock PeerID is empty. Ensure lock is online and WiFi configured.'
|
||
: '锁版PeerID为空,请确认锁已联网并完成WiFi配网。');
|
||
return '';
|
||
} catch (e) {
|
||
final cachedPeerId = (paired.peerId ?? '').trim();
|
||
if (cachedPeerId.isNotEmpty) return cachedPeerId;
|
||
_log_('${S.isEn ? "Fetch lock detail failed" : "获取锁详情失败"}: $e');
|
||
return '';
|
||
} finally {
|
||
_resolvingPeerId = false;
|
||
}
|
||
}
|
||
|
||
void _requestCall() {
|
||
_requestCallAsync();
|
||
}
|
||
|
||
Future<void> _requestCallAsync() async {
|
||
if (_preparingMonitor) return;
|
||
if (_status == TalkStatus.outgoingCall ||
|
||
_status == TalkStatus.inCall ||
|
||
_status == TalkStatus.incomingCall) {
|
||
_log_(S.isEn ? 'Monitor request already active.' : '监控请求已在进行中。');
|
||
return;
|
||
}
|
||
setState(() => _preparingMonitor = true);
|
||
try {
|
||
final target = await _resolveLockPeerId();
|
||
if (target.isEmpty) return;
|
||
|
||
final paired = pairedLock.value;
|
||
_videoLockId = paired?.lockId;
|
||
_videoLockCloudUid = paired?.cloudUid;
|
||
final online = paired?.isOnline == true;
|
||
if (!online) {
|
||
_log_(S.isEn
|
||
? 'Lock appears offline. Monitor may fail if lock has no network access.'
|
||
: '锁当前显示离线,若锁未联网则监控会失败,仍尝试发起...');
|
||
print('Lock is offline, attempting monitor anyway');
|
||
}
|
||
|
||
if (paired != null) {
|
||
_log_(S.isEn
|
||
? 'Enabling cat-eye realtime mode over BLE...'
|
||
: '正在通过 BLE 切换猫眼实时监控模式...');
|
||
await CatEyeMonitorService().enableRealtimeMode(
|
||
lock: paired,
|
||
);
|
||
_log_(S.isEn
|
||
? 'Cat-eye realtime mode enabled over BLE.'
|
||
: '猫眼实时监控模式已通过 BLE 启用。');
|
||
}
|
||
|
||
_log_('Requesting Monitor to $target...');
|
||
print('Requesting Monitor to $target...');
|
||
final decoderReady = await _initDecoder();
|
||
if (!decoderReady) {
|
||
_log_(S.isEn
|
||
? 'Monitor cancelled: decoder unavailable.'
|
||
: '监控已取消:视频解码器初始化失败。');
|
||
return;
|
||
}
|
||
_log_('Decoder initialized, sending monitor request...');
|
||
_isCallMode = false;
|
||
_activeLockPeerId = target;
|
||
StarCloudClient.instance.talk.requestCall(
|
||
lockPeerId: target,
|
||
callerName: 'DemoApp Monitor',
|
||
options: _monitorOptions,
|
||
);
|
||
} catch (e) {
|
||
_log_('${S.isEn ? "Failed to prepare monitor" : "监控准备失败"}: $e');
|
||
print('[VideoTalk] monitor preparation failed: $e');
|
||
} finally {
|
||
if (mounted) setState(() => _preparingMonitor = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _acceptCall() async {
|
||
if (_call == null) return;
|
||
if (!await _initDecoder()) return;
|
||
_videoLockId = pairedLock.value?.lockId;
|
||
_videoLockCloudUid = pairedLock.value?.cloudUid;
|
||
_isCallMode = true;
|
||
_activeLockPeerId = _call!.lockPeerId;
|
||
StarCloudClient.instance.talk.accept(
|
||
lockPeerId: _call!.lockPeerId,
|
||
options: _monitorOptions,
|
||
);
|
||
}
|
||
|
||
void _hangup() {
|
||
final target = (_call?.lockPeerId ?? _activeLockPeerId)?.trim() ?? '';
|
||
if (target.isEmpty) return;
|
||
_log_('Hanging up...');
|
||
print('Hanging up...');
|
||
StarCloudClient.instance.talk.hangup(lockPeerId: target);
|
||
_releaseDecoder();
|
||
}
|
||
|
||
Future<void> _remoteUnlock() async {
|
||
if (_remoteUnlocking) return;
|
||
final paired = pairedLock.value;
|
||
final int? lockId = paired?.lockId ?? _videoLockId;
|
||
if (lockId == null || lockId <= 0) {
|
||
final message = S.isEn ? 'Please pair a lock first.' : '请先配对锁后再试。';
|
||
_log_(message);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(message)),
|
||
);
|
||
return;
|
||
}
|
||
if (wifiConfiguredLockId.value != lockId) {
|
||
final message = S.remoteUnlockNeedsWifi;
|
||
_log_(message);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(message)),
|
||
);
|
||
return;
|
||
}
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (dialogContext) => AlertDialog(
|
||
title: Text(S.confirmRemoteUnlockTitle),
|
||
content: Text(S.confirmRemoteUnlockMessage),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||
child: Text(S.isEn ? 'Cancel' : '取消'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||
child: Text(S.btnRemoteUnlock),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true || !mounted) return;
|
||
|
||
setState(() => _remoteUnlocking = true);
|
||
try {
|
||
final fresh = await StarCloudClient.instance.device.getDevice(
|
||
lockId: lockId,
|
||
cloudUid: paired?.lockId == lockId
|
||
? paired?.cloudUid
|
||
: _videoLockCloudUid,
|
||
);
|
||
final enabledLock = await _ensureRemoteUnlockEnabled(fresh);
|
||
pairedLock.value = enabledLock;
|
||
if (enabledLock.hasGateway == 1) {
|
||
final token = lockServiceAccessToken.value?.trim() ?? '';
|
||
if (token.isEmpty) {
|
||
throw StateError(S.remoteUnlockNeedsLogin);
|
||
}
|
||
if (enabledLock.isOnline != true) {
|
||
throw StateError(S.remoteUnlockNeedsOnline);
|
||
}
|
||
await StarCloudClient.instance.device.remoteOpenLock(
|
||
lockId: enabledLock.lockId,
|
||
lockServiceAccessToken: token,
|
||
);
|
||
} else {
|
||
final activePeerId = (_activeLockPeerId ?? '').trim();
|
||
final String peerId = activePeerId.isNotEmpty
|
||
? activePeerId
|
||
: (enabledLock.peerId ?? '').trim();
|
||
if (peerId.isEmpty || enabledLock.peerId != peerId) {
|
||
throw StateError(S.remoteUnlockPeerUnavailable);
|
||
}
|
||
await StarCloudClient.instance.device.remoteOpenLockViaStarChart(
|
||
lock: enabledLock,
|
||
peerId: peerId,
|
||
talk: StarCloudClient.instance.talk,
|
||
);
|
||
}
|
||
_log_(S.remoteUnlockSuccess);
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(S.remoteUnlockSuccess)),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
final message = e is StateError
|
||
? e.message
|
||
: (e is StarCloudException ? e.message : e.toString());
|
||
_log_('${S.logFailed}: $message');
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(message.toString())),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _remoteUnlocking = false);
|
||
}
|
||
}
|
||
|
||
Future<StarCloudLock> _ensureRemoteUnlockEnabled(
|
||
StarCloudLock lock,
|
||
) async {
|
||
final featureValue = lock.lockFeature['remoteUnlock']?.toString();
|
||
if (featureValue == '1' && lock.lockSetting.remoteUnlock == 1) {
|
||
return lock;
|
||
}
|
||
|
||
_log_(S.isEn
|
||
? 'Remote unlock is disabled in lock details; enabling it over BLE...'
|
||
: '锁详情中的远程开锁未开启,正在通过 BLE 自动开启...');
|
||
final context = await StarCloudLockContext.fromLock(lock);
|
||
final reply = await StarCloudClient.instance.lockSetting
|
||
.setRemoteUnlockByContext(context, enabled: true);
|
||
if (reply.status != 0) {
|
||
final status = reply.status;
|
||
_log_('Failed to enable remote unlock over BLE: status=$status.');
|
||
throw StateError(S.remoteUnlockUnavailable);
|
||
}
|
||
|
||
lock.lockFeature['remoteUnlock'] = 1;
|
||
lock.lockSetting.remoteUnlock = 1;
|
||
_log_(S.isEn ? 'Remote unlock enabled.' : '远程开锁已自动开启。');
|
||
return lock;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: S.langNotifier,
|
||
builder: (context, isEn, _) {
|
||
return Stack(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
ValueListenableBuilder(
|
||
valueListenable: pairedLock,
|
||
builder: (context, lock, _) {
|
||
if (lock == null) return const SizedBox.shrink();
|
||
final online = lock.isOnline == true;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
isEn ? 'Lock status' : '锁状态',
|
||
style: const TextStyle(
|
||
fontSize: 10,
|
||
color: Colors.blueGrey,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: online
|
||
? Colors.green.withOpacity(0.1)
|
||
: Colors.orange.withOpacity(0.1),
|
||
borderRadius: BorderRadius.circular(4),
|
||
border: Border.all(
|
||
color:
|
||
online ? Colors.green : Colors.orange,
|
||
width: 1,
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
decoration: BoxDecoration(
|
||
color: online
|
||
? Colors.green
|
||
: Colors.orange,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
online
|
||
? (isEn ? 'Online' : '在线')
|
||
: (isEn ? 'Offline' : '离线'),
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.bold,
|
||
color: online
|
||
? Colors.green
|
||
: Colors.orange,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 3),
|
||
ValueListenableBuilder<bool>(
|
||
valueListenable: videoTalkReady,
|
||
builder: (context, ready, _) {
|
||
return ValueListenableBuilder<int?>(
|
||
valueListenable: wifiConfiguredLockId,
|
||
builder: (context, configuredLockId, _) {
|
||
final currentLockId =
|
||
pairedLock.value?.lockId ?? _videoLockId;
|
||
final wifiConfigured =
|
||
configuredLockId != null &&
|
||
configuredLockId == currentLockId;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (!ready)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(S.completeWifiTalkInitialization),
|
||
),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: ElevatedButton(
|
||
onPressed:
|
||
!ready || _preparingMonitor
|
||
? null
|
||
: _requestCall,
|
||
child: _preparingMonitor
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: Text(
|
||
S.btnMonitor,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.red,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
onPressed: (_status == TalkStatus.inCall ||
|
||
_status == TalkStatus.incomingCall)
|
||
? _hangup
|
||
: null,
|
||
icon: const Icon(Icons.call_end, size: 18),
|
||
label: Text(
|
||
S.btnHangup,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 4, vertical: 12),
|
||
),
|
||
onPressed:
|
||
wifiConfigured ? _remoteUnlock : null,
|
||
icon: _remoteUnlocking
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: const Icon(Icons.lock_open, size: 18),
|
||
label: Text(
|
||
_remoteUnlocking
|
||
? S.btnProcessing
|
||
: S.btnRemoteUnlock,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 12),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (ready) ...[
|
||
// 视频画面渲染区域
|
||
const SizedBox(height: 3),
|
||
_buildVideoPreview(),
|
||
],
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 3),
|
||
LogArea(log: _log),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: SafeArea(
|
||
child: IgnorePointer(
|
||
ignoring: _call == null,
|
||
child: AnimatedSlide(
|
||
offset: _call == null ? const Offset(0, -1.2) : Offset.zero,
|
||
duration: const Duration(milliseconds: 280),
|
||
curve: Curves.easeOutCubic,
|
||
child: AnimatedOpacity(
|
||
opacity: _call == null ? 0 : 1,
|
||
duration: const Duration(milliseconds: 180),
|
||
child: _call == null
|
||
? const SizedBox.shrink()
|
||
: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||
child: _buildIncomingCallCard(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildIncomingCallCard() {
|
||
final call = _call!;
|
||
return Card(
|
||
color: Colors.blue.shade50,
|
||
elevation: 4,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
S.incomingCall,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
|
||
),
|
||
Text(call.callerName, style: const TextStyle(fontSize: 16)),
|
||
const SizedBox(height: 24),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.green,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
onPressed: _acceptCall,
|
||
icon: const Icon(Icons.call),
|
||
label: Text(S.btnAccept),
|
||
),
|
||
ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.red,
|
||
foregroundColor: Colors.white,
|
||
),
|
||
onPressed: () {
|
||
StarCloudClient.instance.talk
|
||
.reject(lockPeerId: call.lockPeerId);
|
||
setState(() => _call = null);
|
||
},
|
||
icon: const Icon(Icons.call_end),
|
||
label: Text(S.btnReject),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 视频画面渲染区域
|
||
Widget _buildVideoPreview() {
|
||
final isSideways = _videoQuarterTurns.isOdd;
|
||
final sourceAspectRatio = _videoWidth / _videoHeight;
|
||
final displayAspectRatio =
|
||
isSideways ? 1 / sourceAspectRatio : sourceAspectRatio;
|
||
|
||
// 未初始化解码器或无画面时显示占位
|
||
if (!_decoderReady || _textureId == null) {
|
||
return AspectRatio(
|
||
aspectRatio: displayAspectRatio,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.black87,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Colors.grey.shade600, width: 1),
|
||
),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.videocam_off, size: 36, color: Colors.grey.shade400),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
S.isEn ? 'No video stream' : '暂无视频画面',
|
||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
||
),
|
||
if (_status == TalkStatus.inCall)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
S.isEn ? 'Waiting for frames...' : '等待视频帧...',
|
||
style:
|
||
TextStyle(color: Colors.grey.shade500, fontSize: 10),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return AspectRatio(
|
||
aspectRatio: displayAspectRatio,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.black,
|
||
border: Border.all(color: Colors.grey.shade600, width: 1),
|
||
),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// 旋转角由锁端 TalkExpect 协商结果决定。布局比例必须使用
|
||
// 旋转后的宽高,否则会被固定高度的容器压缩。
|
||
RotatedBox(
|
||
quarterTurns: _videoQuarterTurns,
|
||
child: RepaintBoundary(
|
||
child: Texture(
|
||
textureId: _textureId!,
|
||
filterQuality: FilterQuality.medium,
|
||
),
|
||
),
|
||
),
|
||
// 无帧时显示加载指示
|
||
if (!_frameRendered)
|
||
Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator(
|
||
valueColor:
|
||
AlwaysStoppedAnimation<Color>(Colors.white70),
|
||
strokeWidth: 2,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
S.isEn ? 'Initializing...' : '初始化中...',
|
||
style: const TextStyle(
|
||
color: Colors.white70, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _QueuedVideoFrame {
|
||
const _QueuedVideoFrame({
|
||
required this.frameData,
|
||
required this.frameType,
|
||
required this.frameSeq,
|
||
required this.frameSeqI,
|
||
required this.arrivalWallTimeMs,
|
||
});
|
||
|
||
final Uint8List frameData;
|
||
final int frameType;
|
||
final int frameSeq;
|
||
final int frameSeqI;
|
||
final int arrivalWallTimeMs;
|
||
}
|