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 memberIds; final String peerName; final bool incoming; @override State createState() => _CallPageState(); } class _CallPageState extends State { 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 _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.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.delayed(const Duration(milliseconds: 850), () { if (mounted) Navigator.of(context).pop(); }); } Future _boot() async { if (widget.incoming) { if (mounted) setState(() => _status = '对方邀请你${_video ? '视频' : '语音'}通话'); _startTone(); _startRingTimeout(); return; } await _start(); } Future _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.from(auth) : {}; final url = '${data['url'] ?? ''}'; final token = '${data['token'] ?? ''}'; if (url.isEmpty || token.isEmpty) throw Exception('音视频服务未配置'); final room = Room(); _room = room; room.events ..on((_) { _stopTone(); _startDuration(); if (mounted) setState(() => _status = '通话中'); }) ..on((_) { if (!_ending) _finishRemote('通话已结束'); }) ..on((event) { _stopTone(); _startDuration(); if (event.track is VideoTrack && mounted) setState(() => _remoteVideo = event.track as VideoTrack); if (mounted) setState(() => _status = '通话中'); }) ..on((event) { if (event.track is VideoTrack && mounted) setState(() => _remoteVideo = null); }) ..on((_) { if (mounted) setState(() => _status = '网络恢复中…'); }) ..on((_) { if (mounted) setState(() => _status = '通话中'); }) ..on((_) { 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 _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 _toggleMute() async { final next = !_muted; await _room?.localParticipant?.setMicrophoneEnabled(!next); if (mounted) setState(() => _muted = next); } Future _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.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 openCallPage(BuildContext context, {required SessionStore session, required OaClient api, required Map 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))); }