flutter_sdk/example/lib/tabs/video_talk_tab.dart

1334 lines
48 KiB
Dart
Raw Permalink Normal View History

2026-08-20 09:25:05 +08:00
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
2026-10-19 16:17:25 +08:00
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart';
import 'package:starcloud/entity/star_cloud_lock_list.dart';
2026-08-20 09:25:05 +08:00
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> {
2026-10-19 16:17:25 +08:00
2026-08-20 09:25:05 +08:00
static const _maxVideoReorderDistance = 3;
static const _videoReorderWait = Duration(milliseconds: 15);
static const TalkOptions _monitorOptions = TalkOptions(
preferredVideoTypes: [VideoTypeE.H264],
2026-10-19 16:17:25 +08:00
preferredAudioTypes: [AudioTypeE.G711],
2026-08-20 09:25:05 +08:00
);
final List<String> _log = [];
IncomingCallEvent? _call;
TalkStatus _status = TalkStatus.idle;
String? _activeLockPeerId;
// 视频解码器
int? _textureId;
bool _decoderReady = false;
Future<bool>? _decoderInitFuture;
bool _frameRendered = false;
2026-10-19 16:17:25 +08:00
bool _remoteUnlocking = false;
int? _videoLockId;
int? _videoLockCloudUid;
2026-08-20 09:25:05 +08:00
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;
2026-10-19 16:17:25 +08:00
bool _audioReady = false;
Future<void>? _audioInitFuture;
Future<void> _audioQueue = Future<void>.value();
int _audioPacketCount = 0;
int _audioGeneration = 0;
2026-08-20 09:25:05 +08:00
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();
2026-10-19 16:17:25 +08:00
unawaited(_stopAudio());
2026-08-20 09:25:05 +08:00
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;
2026-10-19 16:17:25 +08:00
_videoLockId = pairedLock.value?.lockId;
_videoLockCloudUid = pairedLock.value?.cloudUid;
2026-08-20 09:25:05 +08:00
});
});
_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) {
2026-10-19 16:17:25 +08:00
if (e.status == TalkStatus.timeout ||
e.status == TalkStatus.rejected) {
final paired = pairedLock.value;
_videoLockId = paired?.lockId;
_videoLockCloudUid = paired?.cloudUid;
}
2026-08-20 09:25:05 +08:00
_call = null;
_activeLockPeerId = null;
_cancelVideoFrameTimeout();
2026-10-19 16:17:25 +08:00
unawaited(_stopAudio());
2026-08-20 09:25:05 +08:00
_releaseDecoder();
}
});
}
});
_dataSub = StarCloudClient.instance.talk.onTalkData.listen((e) {
if (!mounted) return;
if (e.isAudio) {
2026-10-19 16:17:25 +08:00
_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`');
});
2026-08-20 09:25:05 +08:00
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();
}
2026-10-19 16:17:25 +08:00
// ==================== 音频播放 ====================
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;
}
2026-08-20 09:25:05 +08:00
// ==================== 视频解码器 ====================
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 {
2026-10-10 17:25:12 +08:00
// video_decode_plugin 的 Android 端默认使用 FFmpeg;这里显式
2026-08-20 09:25:05 +08:00
// 保持同一后端,避免插件默认值变化时两端走出不同的解码流程。
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() {
2026-10-19 16:17:25 +08:00
final paired = pairedLock.value;
if (paired != null && _activeLockPeerId == null) {
_videoLockId = paired.lockId;
_videoLockCloudUid = paired.cloudUid;
}
2026-08-20 09:25:05 +08:00
_startPeerIdPolling();
2026-10-19 16:17:25 +08:00
if (mounted) setState(() {});
2026-08-20 09:25:05 +08:00
}
/// 锁版 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;
2026-10-19 16:17:25 +08:00
_videoLockId = paired?.lockId;
_videoLockCloudUid = paired?.cloudUid;
2026-08-20 09:25:05 +08:00
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;
2026-10-19 16:17:25 +08:00
_videoLockId = pairedLock.value?.lockId;
_videoLockCloudUid = pairedLock.value?.cloudUid;
2026-08-20 09:25:05 +08:00
_isCallMode = true;
_activeLockPeerId = _call!.lockPeerId;
2026-10-19 16:17:25 +08:00
StarCloudClient.instance.talk.accept(
lockPeerId: _call!.lockPeerId,
options: _monitorOptions,
);
2026-08-20 09:25:05 +08:00
}
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();
}
2026-10-19 16:17:25 +08:00
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);
}
2026-10-19 17:11:58 +08:00
if (enabledLock.isOnline != true) {
2026-10-19 16:17:25 +08:00
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;
}
2026-08-20 09:25:05 +08:00
@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, _) {
2026-10-19 16:17:25 +08:00
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),
),
2026-08-20 09:25:05 +08:00
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed:
2026-10-19 16:17:25 +08:00
!ready || _preparingMonitor
? null
: _requestCall,
2026-08-20 09:25:05 +08:00
child: _preparingMonitor
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
2026-10-19 16:17:25 +08:00
: Text(
S.btnMonitor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
2026-08-20 09:25:05 +08:00
),
),
2026-10-19 16:17:25 +08:00
const SizedBox(width: 4),
2026-08-20 09:25:05 +08:00
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: (_status == TalkStatus.inCall ||
_status == TalkStatus.incomingCall)
? _hangup
: null,
2026-10-19 16:17:25 +08:00
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),
),
2026-08-20 09:25:05 +08:00
),
),
],
),
2026-10-19 16:17:25 +08:00
if (ready) ...[
// 视频画面渲染区域
const SizedBox(height: 3),
_buildVideoPreview(),
],
],
);
},
2026-08-20 09:25:05 +08:00
);
},
),
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;
}