76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
437 lines
14 KiB
Dart
437 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:livekit_client/livekit_client.dart';
|
|
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../device/device_bridge.dart';
|
|
import '../session/session.dart';
|
|
import '../theme.dart';
|
|
|
|
/// LiveKit 音视频房间页面。信令和媒体连接均由 LiveKit 负责,OA 只签发短时房间 Token。
|
|
class CallPage extends StatefulWidget {
|
|
const CallPage({
|
|
super.key,
|
|
required this.session,
|
|
required this.api,
|
|
required this.callId,
|
|
required this.kind,
|
|
required this.title,
|
|
required this.initiatorId,
|
|
required this.memberIds,
|
|
this.peerName = '同事',
|
|
this.incoming = false,
|
|
});
|
|
final SessionStore session;
|
|
final OaClient api;
|
|
final String callId;
|
|
final String kind;
|
|
final String title;
|
|
final String initiatorId;
|
|
final List<String> memberIds;
|
|
final String peerName;
|
|
final bool incoming;
|
|
|
|
@override
|
|
State<CallPage> createState() => _CallPageState();
|
|
}
|
|
|
|
class _CallPageState extends State<CallPage> {
|
|
Room? _room;
|
|
VideoTrack? _remoteVideo;
|
|
VideoTrack? _localVideo;
|
|
String _status = '正在连接音视频服务…';
|
|
bool _ready = false;
|
|
bool _muted = false;
|
|
bool _camOff = false;
|
|
bool _ending = false;
|
|
bool _remoteEnded = false;
|
|
bool _tone = false;
|
|
DateTime? _connectedAt;
|
|
Duration _elapsed = Duration.zero;
|
|
Timer? _durationTimer;
|
|
Timer? _ringTimeoutTimer;
|
|
Timer? _statePollTimer;
|
|
bool get _video => widget.kind != 'audio';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.session.im.addListener(_onIm);
|
|
_statePollTimer = Timer.periodic(
|
|
const Duration(seconds: 2), (_) => unawaited(_pollCallState()));
|
|
_boot();
|
|
}
|
|
|
|
void _onIm() {
|
|
if (_ending || _remoteEnded || widget.session.im.inbox.isEmpty) return;
|
|
final matches = widget.session.im.inbox.where((item) =>
|
|
item.kind == 'call' && '${item.raw['callId'] ?? ''}' == widget.callId);
|
|
if (matches.isEmpty) return;
|
|
final last = matches.first;
|
|
final raw = last.raw;
|
|
if (last.kind != 'call' || '${raw['callId'] ?? ''}' != widget.callId)
|
|
return;
|
|
final action = '${raw['action'] ?? ''}';
|
|
if (action != 'end' && action != 'reject' && action != 'timeout') return;
|
|
_finishRemote(action == 'timeout'
|
|
? '无人接听'
|
|
: action == 'reject'
|
|
? '对方拒绝接听'
|
|
: '通话已结束');
|
|
}
|
|
|
|
Future<void> _pollCallState() async {
|
|
if (_ending || _remoteEnded) return;
|
|
try {
|
|
final raw = await widget.api.get('/im/calls/${widget.callId}');
|
|
if (raw is! Map || !mounted) return;
|
|
final data = Map<String, dynamic>.from(raw);
|
|
final status = '${data['status'] ?? ''}';
|
|
if (status == 'ended') {
|
|
_finishRemote(data['peerRejected'] == true ? '对方拒绝接听' : '通话已结束');
|
|
return;
|
|
}
|
|
if (data['peerJoined'] == true && _ready) {
|
|
_stopTone();
|
|
_startDuration();
|
|
if (mounted && _status != '通话中') setState(() => _status = '通话中');
|
|
}
|
|
} catch (_) {
|
|
// 短时断网由 LiveKit 重连处理,状态轮询下一轮继续。
|
|
}
|
|
}
|
|
|
|
void _finishRemote(String text) {
|
|
if (_remoteEnded || _ending) return;
|
|
_remoteEnded = true;
|
|
_ending = true;
|
|
_stopTone();
|
|
_stopDuration();
|
|
_ringTimeoutTimer?.cancel();
|
|
_statePollTimer?.cancel();
|
|
unawaited(_room?.disconnect());
|
|
if (!mounted) return;
|
|
setState(() => _status = text);
|
|
Future<void>.delayed(const Duration(milliseconds: 850), () {
|
|
if (mounted) Navigator.of(context).pop();
|
|
});
|
|
}
|
|
|
|
Future<void> _boot() async {
|
|
if (widget.incoming) {
|
|
if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话');
|
|
_startTone();
|
|
_startRingTimeout();
|
|
return;
|
|
}
|
|
await _start();
|
|
}
|
|
|
|
Future<void> _start() async {
|
|
if (_ready) return;
|
|
if (widget.incoming) _stopTone();
|
|
if (mounted)
|
|
setState(() {
|
|
_ready = true;
|
|
_status = '正在接通…';
|
|
});
|
|
_startTone();
|
|
_startRingTimeout();
|
|
try {
|
|
await DeviceBridge.requestCallMedia();
|
|
await widget.api.post('/im/calls/${widget.callId}/join');
|
|
final auth =
|
|
await widget.api.post('/im/calls/${widget.callId}/livekit-token');
|
|
final data =
|
|
auth is Map ? Map<String, dynamic>.from(auth) : <String, dynamic>{};
|
|
final url = '${data['url'] ?? ''}';
|
|
final token = '${data['token'] ?? ''}';
|
|
if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置');
|
|
final room = Room();
|
|
_room = room;
|
|
room.events
|
|
..on<ParticipantConnectedEvent>((_) {
|
|
_stopTone();
|
|
_startDuration();
|
|
if (mounted) setState(() => _status = '通话中');
|
|
})
|
|
..on<ParticipantDisconnectedEvent>((_) {
|
|
if (!_ending) _finishRemote('通话已结束');
|
|
})
|
|
..on<TrackSubscribedEvent>((event) {
|
|
_stopTone();
|
|
_startDuration();
|
|
if (event.track is VideoTrack && mounted)
|
|
setState(() => _remoteVideo = event.track as VideoTrack);
|
|
if (mounted) setState(() => _status = '通话中');
|
|
})
|
|
..on<TrackUnsubscribedEvent>((event) {
|
|
if (event.track is VideoTrack && mounted)
|
|
setState(() => _remoteVideo = null);
|
|
})
|
|
..on<RoomReconnectingEvent>((_) {
|
|
if (mounted) setState(() => _status = '网络恢复中…');
|
|
})
|
|
..on<RoomReconnectedEvent>((_) {
|
|
if (mounted) setState(() => _status = '通话中');
|
|
})
|
|
..on<RoomDisconnectedEvent>((_) {
|
|
if (!_ending) _finishRemote('通话已断开');
|
|
});
|
|
await room.connect(url, token);
|
|
// 接听方完成信令连接后即可停止响铃;拨打方继续响铃直到对方加入房间。
|
|
if (widget.incoming) _stopTone();
|
|
await room.localParticipant?.setMicrophoneEnabled(true);
|
|
if (_video) {
|
|
final pub = await room.localParticipant?.setCameraEnabled(true);
|
|
if (mounted && pub?.track is VideoTrack)
|
|
setState(() => _localVideo = pub!.track as VideoTrack);
|
|
}
|
|
if (room.remoteParticipants.isNotEmpty) {
|
|
_stopTone();
|
|
_startDuration();
|
|
}
|
|
if (mounted) {
|
|
setState(() => _status = room.remoteParticipants.isNotEmpty
|
|
? '通话中'
|
|
: widget.initiatorId == widget.session.userId
|
|
? '等待对方接听…'
|
|
: '正在建立通话…');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _status = '$e');
|
|
}
|
|
}
|
|
|
|
Future<void> _hangup() async {
|
|
if (_ending) return;
|
|
_ending = true;
|
|
_stopTone();
|
|
_stopDuration();
|
|
_ringTimeoutTimer?.cancel();
|
|
_statePollTimer?.cancel();
|
|
try {
|
|
await widget.api.post(
|
|
'/im/calls/${widget.callId}/${(!_ready && widget.incoming) ? 'reject' : 'end'}');
|
|
} catch (_) {}
|
|
await _room?.disconnect();
|
|
if (mounted) Navigator.of(context).pop();
|
|
}
|
|
|
|
Future<void> _toggleMute() async {
|
|
final next = !_muted;
|
|
await _room?.localParticipant?.setMicrophoneEnabled(!next);
|
|
if (mounted) setState(() => _muted = next);
|
|
}
|
|
|
|
Future<void> _toggleCam() async {
|
|
final next = !_camOff;
|
|
final pub = await _room?.localParticipant?.setCameraEnabled(!next);
|
|
if (mounted)
|
|
setState(() {
|
|
_camOff = next;
|
|
if (!next && pub?.track is VideoTrack)
|
|
_localVideo = pub!.track as VideoTrack;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.session.im.removeListener(_onIm);
|
|
_stopTone();
|
|
_stopDuration();
|
|
_ringTimeoutTimer?.cancel();
|
|
_statePollTimer?.cancel();
|
|
_room?.disconnect();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startTone() {
|
|
if (_tone) return;
|
|
_tone = true;
|
|
DeviceBridge.startCallTone(incoming: widget.incoming);
|
|
}
|
|
|
|
void _stopTone() {
|
|
if (!_tone) return;
|
|
_tone = false;
|
|
DeviceBridge.stopCallTone();
|
|
}
|
|
|
|
void _startDuration() {
|
|
if (_connectedAt != null) return;
|
|
_ringTimeoutTimer?.cancel();
|
|
_ringTimeoutTimer = null;
|
|
_connectedAt = DateTime.now();
|
|
_durationTimer?.cancel();
|
|
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (!mounted || _connectedAt == null) return;
|
|
setState(() => _elapsed = DateTime.now().difference(_connectedAt!));
|
|
});
|
|
if (mounted) setState(() => _elapsed = Duration.zero);
|
|
}
|
|
|
|
void _stopDuration() {
|
|
_durationTimer?.cancel();
|
|
_durationTimer = null;
|
|
}
|
|
|
|
void _startRingTimeout() {
|
|
_ringTimeoutTimer?.cancel();
|
|
_ringTimeoutTimer = Timer(const Duration(seconds: 45), () {
|
|
if (!mounted || _ending || _connectedAt != null) return;
|
|
_ending = true;
|
|
_remoteEnded = true;
|
|
_stopTone();
|
|
unawaited(widget.api
|
|
.post(
|
|
'/im/calls/${widget.callId}/${widget.incoming ? 'reject' : 'end'}')
|
|
.catchError((_) {}));
|
|
setState(() => _status = '无人接听');
|
|
Future<void>.delayed(const Duration(milliseconds: 650), () {
|
|
if (mounted) Navigator.of(context).pop();
|
|
});
|
|
});
|
|
}
|
|
|
|
String get _durationText {
|
|
final h = _elapsed.inHours;
|
|
final m = _elapsed.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final s = _elapsed.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return h > 0 ? '${h.toString().padLeft(2, '0')}:$m:$s' : '$m:$s';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (!didPop) _hangup();
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: const Color(0xFF1C1C1E),
|
|
body: SafeArea(
|
|
child: Stack(children: [
|
|
if (_video && _remoteVideo != null)
|
|
Positioned.fill(
|
|
child: VideoTrackRenderer(_remoteVideo!,
|
|
fit: rtc.RTCVideoViewObjectFit.RTCVideoViewObjectFitCover))
|
|
else
|
|
Center(
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
CircleAvatar(
|
|
radius: 48,
|
|
backgroundColor: kBind,
|
|
child: Text(
|
|
widget.peerName.trim().isEmpty
|
|
? '通'
|
|
: String.fromCharCodes(widget.peerName.runes.take(1)),
|
|
style:
|
|
const TextStyle(color: Colors.white, fontSize: 32))),
|
|
const SizedBox(height: 16),
|
|
Text(widget.peerName,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
Text(_status,
|
|
style:
|
|
const TextStyle(color: Color(0xFFBBBBBB), fontSize: 14)),
|
|
if (_connectedAt != null) ...[
|
|
const SizedBox(height: 6),
|
|
Text(_durationText,
|
|
style: const TextStyle(
|
|
color: Color(0xFFBBBBBB), fontSize: 14)),
|
|
],
|
|
])),
|
|
if (_video && _localVideo != null)
|
|
Positioned(
|
|
right: 16,
|
|
top: 16,
|
|
width: 110,
|
|
height: 160,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: VideoTrackRenderer(_localVideo!,
|
|
mirrorMode: VideoViewMirrorMode.mirror))),
|
|
if (_video && _remoteVideo != null)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
top: 24,
|
|
child: Text(_status,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Colors.white70))),
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 36,
|
|
child:
|
|
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
if (!_ready) ...[
|
|
_round(
|
|
Icons.call_end, const Color(0xFFE64340), _hangup, '拒绝'),
|
|
const SizedBox(width: 48),
|
|
_round(Icons.call, const Color(0xFF07C160), _start, '接听'),
|
|
] else ...[
|
|
_round(
|
|
_muted ? Icons.mic_off : Icons.mic,
|
|
const Color(0xFF3A3A3C),
|
|
_toggleMute,
|
|
_muted ? '开麦' : '静音'),
|
|
if (_video) ...[
|
|
const SizedBox(width: 28),
|
|
_round(_camOff ? Icons.videocam_off : Icons.videocam,
|
|
const Color(0xFF3A3A3C), _toggleCam, '摄像头')
|
|
],
|
|
const SizedBox(width: 28),
|
|
_round(
|
|
Icons.call_end, const Color(0xFFE64340), _hangup, '挂断'),
|
|
],
|
|
])),
|
|
])),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _round(IconData icon, Color color, VoidCallback onTap, String label) =>
|
|
Column(mainAxisSize: MainAxisSize.min, children: [
|
|
InkWell(
|
|
onTap: onTap,
|
|
customBorder: const CircleBorder(),
|
|
child: CircleAvatar(
|
|
radius: 28,
|
|
backgroundColor: color,
|
|
child: Icon(icon, color: Colors.white, size: 26))),
|
|
const SizedBox(height: 8),
|
|
Text(label,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
|
]);
|
|
}
|
|
|
|
Future<void> openCallPage(BuildContext context,
|
|
{required SessionStore session,
|
|
required OaClient api,
|
|
required Map<String, dynamic> call,
|
|
String peerName = '同事',
|
|
bool incoming = false}) async {
|
|
final id = '${call['id'] ?? ''}';
|
|
if (id.isEmpty) return;
|
|
final members =
|
|
((call['memberIds'] as List?) ?? []).map((e) => '$e').toList();
|
|
await Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => CallPage(
|
|
session: session,
|
|
api: api,
|
|
callId: id,
|
|
kind: '${call['kind'] ?? 'audio'}',
|
|
title: '${call['title'] ?? '通话'}',
|
|
initiatorId: '${call['initiatorId'] ?? ''}',
|
|
memberIds: members,
|
|
peerName: peerName,
|
|
incoming: incoming)));
|
|
}
|