Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
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)));
|
||||
}
|
||||
Reference in New Issue
Block a user