flutter_sdk/example/lib/tabs/video_talk_tab.dart

1045 lines
37 KiB
Dart
Raw Normal View History

2026-08-20 09:25:05 +08:00
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<VideoTalkTab> createState() => _VideoTalkTabState();
}
class _VideoTalkTabState extends State<VideoTalkTab> {
// 与参考项目保持一致:为 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<String> _log = [];
IncomingCallEvent? _call;
TalkStatus _status = TalkStatus.idle;
String? _activeLockPeerId;
// 视频解码器
int? _textureId;
bool _decoderReady = false;
Future<bool>? _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<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;
});
});
_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<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() {
_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<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;
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;
_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<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, _) {
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<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;
}