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 createState() => _VideoTalkTabState(); } class _VideoTalkTabState extends State { static const _maxVideoReorderDistance = 3; static const _videoReorderWait = Duration(milliseconds: 15); static const TalkOptions _monitorOptions = TalkOptions( preferredVideoTypes: [VideoTypeE.H264], preferredAudioTypes: [AudioTypeE.G711], ); final List _log = []; IncomingCallEvent? _call; TalkStatus _status = TalkStatus.idle; String? _activeLockPeerId; // 视频解码器 int? _textureId; bool _decoderReady = false; Future? _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? _audioInitFuture; Future _audioQueue = Future.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 _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 _ensureAudioReady() { if (_audioReady) return Future.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 _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 _playAudioPacket(Uint8List g711Data, int generation) async { if (g711Data.isEmpty) return; if (generation != _audioGeneration) return; await _ensureAudioReady(); if (!_audioReady || generation != _audioGeneration) return; final pcm = [ 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 _stopAudio() async { _audioGeneration++; _audioQueue = Future.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 _initDecoder() { if (_decoderReady) return Future.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 _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 _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 _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 _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 _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 _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 ? "" : freshPeerId}'); print('[VideoTalk] Lock status: lockId=${paired.lockId}, ' 'cloudOnline=$online, peerId=${freshPeerId.isEmpty ? "" : 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 _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 _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 _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( 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 _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( 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( valueListenable: videoTalkReady, builder: (context, ready, _) { return ValueListenableBuilder( 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(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; }