Files
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

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)));
}