flutter_sdk/example/lib/tabs/video_talk_tab.dart
2026-10-10 17:26:24 +08:00

1045 lines
37 KiB
Dart
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
// video_decode_plugin 的 Android 端默认使用 FFmpeg;这里显式
// 保持同一后端,避免插件默认值变化时两端走出不同的解码流程。
final engineState = await VideoDecodePlugin.setDecodeEngine(
VideoDecodePlugin.engineFfmpeg,
applyImmediately: false,
);
final activeEngine = engineState['activeEngine']?.toString();
_log_('Video decoder engine: $activeEngine');
if (activeEngine != VideoDecodePlugin.engineFfmpeg) {
_log_('FFmpeg video decoder is unavailable; monitor was not started.');
return false;
}
final config = VideoDecoderConfig(
width: _videoWidth,
height: _videoHeight,
codecType: 'h264',
);
final tid = await VideoDecodePlugin.initDecoder(config);
if (tid == null) {
_log_('Video decoder init failed: null textureId');
print('Video decoder init failed: null textureId');
return false;
}
// initDecoder 后再次确认后端,保证整个会话都与参考项目一致。
final initializedEngine =
(await VideoDecodePlugin.getDecodeEngine())['activeEngine']
?.toString();
if (initializedEngine != VideoDecodePlugin.engineFfmpeg) {
await VideoDecodePlugin.releaseDecoder();
_log_(
'FFmpeg video decoder failed to start (active=$initializedEngine).');
return false;
}
_textureId = tid;
_decoderReady = true;
_frameRendered = false;
_videoCount = 0;
_framePtsBaseWallTimeMs = null;
_lastFramePtsMs = -1;
_highestVideoFrameSeq = -1;
_lastDecodedIFrameSeq = null;
_waitingForIFrame = true;
_videoProcessTimer?.cancel();
_videoProcessTimer = Timer.periodic(
const Duration(milliseconds: 5),
(_) => _processNextVideoFrame(),
);
VideoDecodePlugin.setOnFrameRenderedListener((textureId) {
_frameRendered = true;
_log_('First decoded video frame rendered (textureId=$textureId)');
if (mounted) setState(() {});
});
VideoDecodePlugin.setOnMissingSpsPpsListener(() {
if (!mounted || _status != TalkStatus.inCall) return;
_log_(S.isEn
? 'Missing H264 SPS/PPS, requesting a key frame...'
: '缺少 H264 SPS/PPS,正在重新请求关键帧...');
StarCloudClient.instance.talk.renegotiate(
options: _isCallMode ? TalkOptions.defaultOptions : _monitorOptions,
);
});
_log_('Video decoder ready (textureId=$tid)');
print('Video decoder ready (textureId=$tid)');
if (mounted) setState(() {});
return true;
} catch (e) {
_log_('Video decoder init error: $e');
print('Video decoder init error: $e');
return false;
}
}
Future<void> _releaseDecoder() async {
if (!_decoderReady) return;
try {
await VideoDecodePlugin.releaseDecoder();
} catch (_) {}
_textureId = null;
_decoderReady = false;
_frameRendered = false;
_decoderInitFuture = null;
_videoSendGeneration++;
_pendingVideoFrame = null;
_sendingVideoFrame = false;
_videoProcessTimer?.cancel();
_videoProcessTimer = null;
_videoFrameTimeoutTimer?.cancel();
_videoFrameTimeoutTimer = null;
_videoBuffer.clear();
_highestVideoFrameSeq = -1;
_lastDecodedIFrameSeq = null;
_waitingForIFrame = true;
_videoCount = 0;
_framePtsBaseWallTimeMs = null;
_lastFramePtsMs = -1;
if (mounted) setState(() {});
}
Future<void> _recreateDecoderForResolution() async {
await _releaseDecoder();
if (mounted) await _initDecoder();
}
void _startVideoFrameTimeout() {
_cancelVideoFrameTimeout();
_videoFrameTimeoutTimer = Timer(const Duration(seconds: 10), () {
if (_videoCount == 0 && mounted) {
_log_(S.isEn
? 'No video frames received within 10s. Check camera and video settings.'
: '10秒内未收到视频帧,请检查摄像头及视频设置。');
print('Video frame timeout: no frames received in 10s');
}
});
}
void _cancelVideoFrameTimeout() {
_videoFrameTimeoutTimer?.cancel();
_videoFrameTimeoutTimer = null;
}
/// 将 TalkDataEvent 中的视频帧送入解码器
void _feedVideoFrame(TalkDataEvent e) {
if (!_decoderReady || _textureId == null) return;
try {
final Uint8List frameData = e.content;
if (e.isJpeg) {
return;
}
if (!e.isH264) return;
if (frameData.isEmpty) return;
final metadata = e.h264Frame;
final bool hasValidMetadata = metadata != null &&
(metadata.frameType.value == 1 || metadata.frameType.value == 2);
if (!hasValidMetadata) {
_log_('Invalid H264 metadata; dropping frame');
print('[VideoTalk] invalid H264 metadata, dropping frame');
return;
}
final bool isIFrame = metadata.frameType.value == 1;
final int frameType = isIFrame ? 0 : 1;
final int frameSeq = metadata.frameSeq;
final int frameSeqI = metadata.frameSeqI;
// 对齐参考项目:检测到锁端视频流重启(I 帧序号大幅回退)时,
// 清除旧 GOP 的待处理帧,立即从新的 I 帧恢复。
if (isIFrame &&
_highestVideoFrameSeq >= 0 &&
frameSeq < _highestVideoFrameSeq) {
final rolloverThreshold =
_getFrameSeqRolloverThreshold(_highestVideoFrameSeq);
if (_highestVideoFrameSeq - frameSeq > rolloverThreshold) {
_log_(
'Video stream reset detected; resuming from I-frame $frameSeq.');
_videoBuffer.clear();
_pendingVideoFrame = null;
_lastDecodedIFrameSeq = null;
_waitingForIFrame = false;
_highestVideoFrameSeq = frameSeq;
} else {
return;
}
} else if (_highestVideoFrameSeq >= 0 &&
frameSeq <= _highestVideoFrameSeq) {
if (_videoBuffer.any((frame) => frame.frameSeq == frameSeq)) {
return;
}
if (_highestVideoFrameSeq - frameSeq > _maxVideoReorderDistance) {
return;
}
}
if (frameSeq > _highestVideoFrameSeq) {
_highestVideoFrameSeq = frameSeq;
}
if (isIFrame) {
_waitingForIFrame = false;
_videoBuffer.removeWhere((f) => f.frameType == 1);
} else if (_waitingForIFrame ||
(_lastDecodedIFrameSeq != null &&
frameSeqI != 0 &&
frameSeqI != _lastDecodedIFrameSeq)) {
if (_lastDecodedIFrameSeq != null && frameSeqI != 0) {
_waitingForIFrame = true;
_videoBuffer.removeWhere((f) => f.frameType == 1);
}
return;
}
_videoBuffer.add(_QueuedVideoFrame(
frameData: frameData,
frameType: frameType,
frameSeq: frameSeq,
frameSeqI: frameSeqI,
arrivalWallTimeMs: DateTime.now().millisecondsSinceEpoch,
));
_videoBuffer.sort((a, b) => a.frameSeq.compareTo(b.frameSeq));
while (_videoBuffer.length > 6) {
final index = _videoBuffer.indexWhere((f) => f.frameType == 1);
_videoBuffer.removeAt(index >= 0 ? index : 0);
}
} catch (err) {
_log_('Frame feed error: $err');
print('Frame feed error: $err');
}
}
void _processNextVideoFrame() {
if (_videoBuffer.isEmpty || !_decoderReady) return;
var index = _videoBuffer.indexWhere((f) => f.frameType == 0);
if (index >= 0) {
final keyFrameSeq = _videoBuffer[index].frameSeq;
_videoBuffer
.removeWhere((f) => f.frameType == 1 && f.frameSeq < keyFrameSeq);
index = _videoBuffer
.indexWhere((f) => f.frameType == 0 && f.frameSeq == keyFrameSeq);
final frame = _videoBuffer[index];
if (_lastDecodedIFrameSeq != null &&
_shouldWaitForReorderedFrame(frame)) {
return;
}
} else if (_lastDecodedIFrameSeq != null) {
_videoBuffer.removeWhere((f) =>
f.frameType == 1 &&
f.frameSeqI != 0 &&
f.frameSeqI != _lastDecodedIFrameSeq);
index = _videoBuffer.indexWhere((f) => f.frameType == 1);
if (index >= 0 && _shouldWaitForReorderedFrame(_videoBuffer[index])) {
return;
}
}
if (index < 0) {
return;
}
final frame = _videoBuffer.removeAt(index);
_submitVideoFrame(frame);
}
/// 参考项目 VideoPipelineV2:解码器忙碌时只保留最新帧,关键帧优先。
/// 这样不会让过期 P 帧持续堆积,画面能尽快追上实时流。
void _submitVideoFrame(_QueuedVideoFrame frame) {
if (frame.frameType == 0) {
_lastDecodedIFrameSeq = frame.frameSeq;
}
if (!_sendingVideoFrame) {
_sendingVideoFrame = true;
unawaited(_drainVideoFrames(frame, _videoSendGeneration));
return;
}
final pending = _pendingVideoFrame;
if (frame.frameType == 0 || pending == null || pending.frameType != 0) {
_pendingVideoFrame = frame;
}
}
Future<void> _drainVideoFrames(
_QueuedVideoFrame firstFrame,
int generation,
) async {
var frame = firstFrame;
while (_decoderReady && generation == _videoSendGeneration) {
try {
await VideoDecodePlugin.sendFrame(
frameData: frame.frameData,
frameType: frame.frameType,
timestamp: _nextFrameTimestamp(frame.arrivalWallTimeMs),
ingressWallTimeMs: frame.arrivalWallTimeMs,
frameSeq: frame.frameSeq,
refIFrameSeq: frame.frameSeqI,
// TalkDataH264Frame.frameData 已经是完整 Annex-B 数据。
// 原生解码器会自行缓存 SPS/PPS 并处理 I/P NALU;这里不能再次
// 在 Dart 层拆分 I 帧,否则会丢失设备发送的有效切片格式。
splitNalFromIFrame: false,
);
} catch (err) {
_log_('Frame decode error: $err');
}
if (!_decoderReady || generation != _videoSendGeneration) {
return;
}
final nextFrame = _pendingVideoFrame;
_pendingVideoFrame = null;
if (nextFrame == null) {
_sendingVideoFrame = false;
return;
}
frame = nextFrame;
}
if (generation != _videoSendGeneration) {
return;
}
_pendingVideoFrame = null;
_sendingVideoFrame = false;
}
int _nextFrameTimestamp(int arrivalWallTimeMs) {
_framePtsBaseWallTimeMs ??= arrivalWallTimeMs;
final candidate = arrivalWallTimeMs - _framePtsBaseWallTimeMs!;
final timestamp =
candidate > _lastFramePtsMs ? candidate : _lastFramePtsMs + 1;
_lastFramePtsMs = timestamp;
return timestamp;
}
bool _shouldWaitForReorderedFrame(_QueuedVideoFrame frame) {
return _highestVideoFrameSeq - frame.frameSeq < _maxVideoReorderDistance &&
DateTime.now().millisecondsSinceEpoch - frame.arrivalWallTimeMs <
_videoReorderWait.inMilliseconds;
}
int _getFrameSeqRolloverThreshold(int lastSeq) {
return lastSeq > 2000 ? 1000 : (lastSeq / 2).round();
}
// ==================== PeerID 管理 ====================
void _onPairedLockChanged() {
_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;
}