import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.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 { // 与参考项目保持一致:为 UDP 帧保留一个很短的乱序窗口,避免 P 帧 // 在前序帧尚未到达时被过早提交给解码器。 static const _maxVideoReorderDistance = 3; static const _videoReorderWait = Duration(milliseconds: 15); // 参考锁端原生 App:主动“监控”只请求 H264 视频。部分固件在首个 // TalkExpect 同时携带 G711 音频时不会回复协商包,最终导致请求超时。 static const TalkOptions _monitorOptions = TalkOptions( preferredVideoTypes: [VideoTypeE.H264], preferredAudioTypes: [], ); final List _log = []; IncomingCallEvent? _call; TalkStatus _status = TalkStatus.idle; String? _activeLockPeerId; // 视频解码器 int? _textureId; bool _decoderReady = false; Future? _decoderInitFuture; bool _frameRendered = false; 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; 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(); 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; }); }); _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) { _call = null; _activeLockPeerId = null; _cancelVideoFrameTimeout(); _releaseDecoder(); } }); } }); _dataSub = StarCloudClient.instance.talk.onTalkData.listen((e) { if (!mounted) return; if (e.isAudio) { 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 _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() { _startPeerIdPolling(); } /// 锁版 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; 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; _isCallMode = true; _activeLockPeerId = _call!.lockPeerId; StarCloudClient.instance.talk.accept(lockPeerId: _call!.lockPeerId); } 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(); } @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, _) { if (!ready) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(S.completeWifiTalkInitialization), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: ElevatedButton( onPressed: _preparingMonitor ? null : _requestCall, child: _preparingMonitor ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Text(S.btnMonitor), ), ), const SizedBox(width: 8), 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), label: Text(S.btnHangup), ), ), ], ), // 视频画面渲染区域 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; }