feat(calls): Тяжело...... Звонки. Вроде полностью. Почти.
This commit is contained in:
@@ -9,10 +9,32 @@ import 'call_info.dart';
|
||||
import 'conversation_params.dart';
|
||||
import 'ws2_signaling.dart';
|
||||
|
||||
enum CallRole { caller, callee }
|
||||
enum CallRole { caller, callee, joiner }
|
||||
|
||||
enum CallSessionState { connecting, ringing, active, ended }
|
||||
|
||||
class CallParticipant {
|
||||
final int id;
|
||||
final bool isSelf;
|
||||
int? externalId;
|
||||
String state;
|
||||
bool audioEnabled;
|
||||
bool videoEnabled;
|
||||
bool screenSharing;
|
||||
bool handRaised;
|
||||
|
||||
CallParticipant({
|
||||
required this.id,
|
||||
this.isSelf = false,
|
||||
this.externalId,
|
||||
this.state = '',
|
||||
this.audioEnabled = true,
|
||||
this.videoEnabled = false,
|
||||
this.screenSharing = false,
|
||||
this.handRaised = false,
|
||||
});
|
||||
}
|
||||
|
||||
class CallSession {
|
||||
final Ws2Config ws2Config;
|
||||
|
||||
@@ -44,6 +66,39 @@ class CallSession {
|
||||
final List<RTCIceCandidate> _pendingCandidates = [];
|
||||
Future<void> _tail = Future.value();
|
||||
|
||||
final Map<int, CallParticipant> _participants = {};
|
||||
|
||||
String? _topology;
|
||||
List _iceServers = const [];
|
||||
Object? _sfuSessionId;
|
||||
Set<int> _speaking = const {};
|
||||
|
||||
bool _localVideo = false;
|
||||
bool _localScreen = false;
|
||||
MediaStream? _localVideoStream;
|
||||
RTCRtpSender? _videoSender;
|
||||
|
||||
Timer? _levelTimer;
|
||||
final Map<int, int> _speakHold = {};
|
||||
|
||||
static const double _speakLevelOn = 0.05;
|
||||
static const int _speakHoldTicks = 3;
|
||||
|
||||
bool get localVideo => _localVideo;
|
||||
bool get localScreen => _localScreen;
|
||||
MediaStream? get localVideoStream => _localVideoStream;
|
||||
|
||||
List<CallParticipant> get participants =>
|
||||
_participants.values.toList(growable: false);
|
||||
|
||||
int get participantCount => _participants.length;
|
||||
|
||||
bool isSpeaking(int id) => _speaking.contains(id);
|
||||
|
||||
String? get topology => _topology;
|
||||
|
||||
bool get _wantVideo => params?.isVideo == true;
|
||||
|
||||
final CallInfo info = CallInfo();
|
||||
|
||||
final _state = StreamController<CallSessionState>.broadcast();
|
||||
@@ -88,6 +143,49 @@ class CallSession {
|
||||
signaling.notifications.listen(_enqueue, onError: (_) => _end());
|
||||
signaling.done.then((_) => _end());
|
||||
await signaling.connect();
|
||||
_levelTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 300), (_) => unawaited(_sampleLevels()));
|
||||
}
|
||||
|
||||
Future<void> _sampleLevels() async {
|
||||
final pc = _pc;
|
||||
if (pc == null || _ended) return;
|
||||
|
||||
var local = 0.0;
|
||||
var remote = 0.0;
|
||||
try {
|
||||
for (final r in await pc.getStats()) {
|
||||
final lvl = r.values['audioLevel'];
|
||||
if (lvl is! num) continue;
|
||||
final kind = r.values['kind'] ?? r.values['mediaType'];
|
||||
if (kind != 'audio') continue;
|
||||
if (r.type == 'media-source') {
|
||||
local = lvl.toDouble();
|
||||
} else if (r.type == 'inbound-rtp') {
|
||||
final v = lvl.toDouble();
|
||||
if (v > remote) remote = v;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final loud = <int>{};
|
||||
if (!_muted && local > _speakLevelOn) loud.add(ws2Config.userId);
|
||||
final others = _participants.values.where((p) => !p.isSelf).toList();
|
||||
if (others.length == 1 && remote > _speakLevelOn) loud.add(others.first.id);
|
||||
|
||||
for (final id in loud) {
|
||||
_speakHold[id] = _speakHoldTicks;
|
||||
}
|
||||
_speakHold.updateAll((id, ticks) => loud.contains(id) ? ticks : ticks - 1);
|
||||
_speakHold.removeWhere((_, ticks) => ticks <= 0);
|
||||
|
||||
final next = _speakHold.keys.toSet();
|
||||
if (next.length != _speaking.length || !next.containsAll(_speaking)) {
|
||||
_speaking = next;
|
||||
_notifyInfo();
|
||||
}
|
||||
}
|
||||
|
||||
void _enqueue(Map<String, dynamic> msg) {
|
||||
@@ -95,6 +193,10 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> _onNotification(Map<String, dynamic> msg) async {
|
||||
if (msg['type'] == 'error') {
|
||||
_onWs2Error(msg);
|
||||
return;
|
||||
}
|
||||
_applyPeerMedia(msg);
|
||||
switch (msg['notification']) {
|
||||
case 'connection':
|
||||
@@ -109,67 +211,453 @@ class CallSession {
|
||||
case 'registered-peer':
|
||||
_applyRegisteredPeer(msg);
|
||||
break;
|
||||
case 'participant-joined':
|
||||
case 'media-settings-changed':
|
||||
_onParticipantMedia(msg);
|
||||
break;
|
||||
case 'participant-state-changed':
|
||||
_onParticipantStateChanged(msg);
|
||||
break;
|
||||
case 'participants-state-changed':
|
||||
_onParticipantsStateChanged(msg);
|
||||
break;
|
||||
case 'participant-left':
|
||||
case 'participant-removed':
|
||||
_onParticipantLeft(msg);
|
||||
break;
|
||||
case 'force-media-settings-change':
|
||||
case 'switch-micro':
|
||||
_onForcedMedia(msg);
|
||||
break;
|
||||
case 'mute-participant':
|
||||
_onMuteParticipant(msg);
|
||||
break;
|
||||
case 'hungup':
|
||||
_onHungup(msg);
|
||||
break;
|
||||
case 'topology-changed':
|
||||
await _onTopologyChanged(msg);
|
||||
break;
|
||||
case 'producer-updated':
|
||||
await _onProducerUpdated(msg);
|
||||
break;
|
||||
case 'session-state':
|
||||
_onSessionState(msg);
|
||||
break;
|
||||
case 'closed-conversation':
|
||||
_end();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _onWs2Error(Map<String, dynamic> msg) {
|
||||
final err = msg['error'];
|
||||
logger.t('[call] ws2 error: $err');
|
||||
if (err == 'conversation-ended') _end();
|
||||
}
|
||||
|
||||
int? _participantIdFrom(Object? raw) {
|
||||
if (raw is int) return raw;
|
||||
if (raw is! String) return null;
|
||||
for (final seg in raw.split(':')) {
|
||||
if (seg.isEmpty) continue;
|
||||
final c = seg[0];
|
||||
if (c == 'u' || c == 'g') {
|
||||
final v = int.tryParse(seg.substring(1));
|
||||
if (v != null) return v;
|
||||
} else if (c != 'd') {
|
||||
final v = int.tryParse(seg);
|
||||
if (v != null) return v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onForcedMedia(Map<String, dynamic> msg) {
|
||||
bool? audioOn;
|
||||
final ms = msg['mediaSettings'];
|
||||
if (ms is Map && ms['isAudioEnabled'] is bool) {
|
||||
audioOn = ms['isAudioEnabled'] as bool;
|
||||
}
|
||||
final muteStates = msg['muteStates'];
|
||||
if (muteStates is Map && muteStates['AUDIO'] is String) {
|
||||
audioOn = muteStates['AUDIO'] == 'UNMUTE';
|
||||
}
|
||||
final mute = msg['mute'];
|
||||
if (mute is bool) audioOn = !mute;
|
||||
if (audioOn == null) return;
|
||||
logger.t('[call] forced media audioEnabled=$audioOn raw=$msg');
|
||||
_applyMuted(!audioOn);
|
||||
}
|
||||
|
||||
void _onMuteParticipant(Map<String, dynamic> msg) {
|
||||
final muteStates = msg['muteStates'];
|
||||
if (muteStates is! Map || muteStates['AUDIO'] is! String) return;
|
||||
final audioOn = muteStates['AUDIO'] == 'UNMUTE';
|
||||
final target = _participantIdFrom(msg['participantId']);
|
||||
final muteAll = msg['muteAll'] == true;
|
||||
|
||||
if (target != null) {
|
||||
final p = _participants[target];
|
||||
if (p != null) {
|
||||
p.audioEnabled = audioOn;
|
||||
_notifyInfo();
|
||||
}
|
||||
}
|
||||
if (muteAll || target == null || target == ws2Config.userId) {
|
||||
_applyMuted(!audioOn);
|
||||
}
|
||||
}
|
||||
|
||||
void _onHungup(Map<String, dynamic> msg) {
|
||||
final raw = msg['participantId'] ??
|
||||
(msg['participant'] is Map ? (msg['participant'] as Map)['id'] : null);
|
||||
if (raw is! int) return;
|
||||
if (raw == ws2Config.userId) {
|
||||
_end();
|
||||
return;
|
||||
}
|
||||
if (_participants.remove(raw) != null) _notifyInfo();
|
||||
}
|
||||
|
||||
void _onSessionState(Map<String, dynamic> msg) {
|
||||
logger.t(
|
||||
'[call][sfu] session-state id=${msg['participantId']} connected=${msg['connected']}');
|
||||
}
|
||||
|
||||
void _resolveParticipants(Object? conversation) {
|
||||
if (conversation is! Map) return;
|
||||
final list = conversation['participants'];
|
||||
if (list is! List) return;
|
||||
final seen = <int>{};
|
||||
for (final p in list.whereType<Map>()) {
|
||||
final id = p['id'];
|
||||
if (id is! int) continue;
|
||||
seen.add(id);
|
||||
_upsertParticipant(
|
||||
id,
|
||||
externalId: _externalId(p['externalId']),
|
||||
state: p['state'] as String?,
|
||||
mediaSettings: p['mediaSettings'],
|
||||
muteStates: p['muteStates'],
|
||||
);
|
||||
}
|
||||
_participants.removeWhere((key, _) => !seen.contains(key));
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
CallParticipant _upsertParticipant(
|
||||
int id, {
|
||||
int? externalId,
|
||||
String? state,
|
||||
Object? mediaSettings,
|
||||
Object? muteStates,
|
||||
bool? handRaised,
|
||||
}) {
|
||||
final p = _participants.putIfAbsent(
|
||||
id,
|
||||
() => CallParticipant(id: id, isSelf: id == ws2Config.userId),
|
||||
);
|
||||
if (externalId != null) p.externalId = externalId;
|
||||
if (state != null) p.state = state;
|
||||
if (mediaSettings is Map) {
|
||||
final a = mediaSettings['isAudioEnabled'];
|
||||
final v = mediaSettings['isVideoEnabled'];
|
||||
final s = mediaSettings['isScreenSharingEnabled'];
|
||||
if (a is bool) p.audioEnabled = a;
|
||||
if (v is bool) p.videoEnabled = v;
|
||||
if (s is bool) p.screenSharing = s;
|
||||
}
|
||||
if (muteStates is Map) {
|
||||
final a = muteStates['AUDIO'];
|
||||
final v = muteStates['VIDEO'];
|
||||
final s = muteStates['SCREEN_SHARING'];
|
||||
if (a is String) p.audioEnabled = a == 'UNMUTE';
|
||||
if (v is String) p.videoEnabled = v == 'UNMUTE';
|
||||
if (s is String) p.screenSharing = s == 'UNMUTE';
|
||||
}
|
||||
if (handRaised != null) p.handRaised = handRaised;
|
||||
return p;
|
||||
}
|
||||
|
||||
int? _externalId(Object? ext) {
|
||||
if (ext is! Map) return null;
|
||||
final v = ext['id'];
|
||||
if (v is int) return v;
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
bool? _handFrom(Object? participantState) {
|
||||
if (participantState is! Map) return null;
|
||||
final state = participantState['state'];
|
||||
if (state is! Map || !state.containsKey('hand')) return null;
|
||||
return state['hand'] == '1' || state['hand'] == true;
|
||||
}
|
||||
|
||||
void _onParticipantMedia(Map<String, dynamic> msg) {
|
||||
final id = _participantIdFrom(msg['participantId']);
|
||||
if (id == null) return;
|
||||
_upsertParticipant(
|
||||
id,
|
||||
externalId: _externalId(msg['externalId']),
|
||||
mediaSettings: msg['mediaSettings'],
|
||||
muteStates: msg['muteStates'],
|
||||
);
|
||||
_maybeAdoptPeer(msg);
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
void _maybeAdoptPeer(Map<String, dynamic> msg) {
|
||||
if (role != CallRole.joiner || _peerId != null || _pc == null) return;
|
||||
if (_topology == 'SERVER') return;
|
||||
final id = msg['participantId'];
|
||||
if (id is! int || id == ws2Config.userId) return;
|
||||
_peerId = id;
|
||||
final type = msg['participantType'];
|
||||
if (type is String && type.isNotEmpty) _peerType = type;
|
||||
final deviceIdx = msg['deviceIdx'];
|
||||
if (deviceIdx is int) _peerDeviceIdx = deviceIdx;
|
||||
logger.t('[call] adopting peer $_peerId on join');
|
||||
unawaited(_createAndSendOffer());
|
||||
}
|
||||
|
||||
void _onParticipantStateChanged(Map<String, dynamic> msg) {
|
||||
final id = msg['participantId'];
|
||||
if (id is! int) return;
|
||||
_upsertParticipant(id, handRaised: _handFrom(msg['participantState']));
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
void _onParticipantsStateChanged(Map<String, dynamic> msg) {
|
||||
final list = msg['participants'];
|
||||
if (list is! List) return;
|
||||
for (final p in list.whereType<Map>()) {
|
||||
final id = _participantIdFrom(p['participantId'] ?? p['id']);
|
||||
if (id == null) continue;
|
||||
_upsertParticipant(
|
||||
id,
|
||||
externalId: _externalId(p['externalId']),
|
||||
state: p['state'] as String?,
|
||||
mediaSettings: p['mediaSettings'],
|
||||
muteStates: p['muteStates'],
|
||||
handRaised: _handFrom(p['participantState']),
|
||||
);
|
||||
}
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
void _onParticipantLeft(Map<String, dynamic> msg) {
|
||||
final id = msg['participantId'];
|
||||
if (id is! int) return;
|
||||
if (_participants.remove(id) != null) _notifyInfo();
|
||||
}
|
||||
|
||||
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
||||
final convParams = msg['conversationParams'];
|
||||
final conversation = msg['conversation'];
|
||||
|
||||
final iceServers =
|
||||
_iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
||||
final ice = _iceServersFrom(convParams) ?? params?.iceServers ?? const [];
|
||||
_iceServers = ice;
|
||||
_resolvePeer(conversation);
|
||||
_applyConnectionInfo(msg, iceServers);
|
||||
_resolveParticipants(conversation);
|
||||
_applyConnectionInfo(msg, ice);
|
||||
|
||||
logger.t('[call] connection — role=$role peer=$_peerId');
|
||||
_topology =
|
||||
(conversation is Map ? conversation['topology']?.toString() : null) ??
|
||||
_topology;
|
||||
logger.t('[call] connection role=$role peer=$_peerId topology=$_topology');
|
||||
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': iceServers,
|
||||
'sdpSemantics': 'unified-plan',
|
||||
});
|
||||
_pc = pc;
|
||||
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': false,
|
||||
});
|
||||
for (final track in _localStream!.getTracks()) {
|
||||
await pc.addTrack(track, _localStream!);
|
||||
if (_topology == 'SERVER') {
|
||||
await _setupSfu();
|
||||
return;
|
||||
}
|
||||
|
||||
final pc = await _createPc(ice);
|
||||
_pc = pc;
|
||||
await _addLocalMedia(pc);
|
||||
|
||||
await pc.addTransceiver(
|
||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
||||
);
|
||||
|
||||
if (role == CallRole.caller) {
|
||||
_setState(CallSessionState.ringing);
|
||||
await _createAndSendOffer();
|
||||
} else if (role == CallRole.joiner) {
|
||||
await _createAndSendOffer();
|
||||
}
|
||||
}
|
||||
|
||||
Future<RTCPeerConnection> _createPc(List ice) async {
|
||||
final pc = await createPeerConnection({
|
||||
'iceServers': ice,
|
||||
'sdpSemantics': 'unified-plan',
|
||||
'bundlePolicy': 'max-bundle',
|
||||
'rtcpMuxPolicy': 'require',
|
||||
});
|
||||
pc.onIceCandidate = _onLocalCandidate;
|
||||
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
||||
pc.onIceConnectionState = (s) => logger.t('[call] ice $s');
|
||||
pc.onConnectionState = (s) {
|
||||
logger.t('[call] pc $s');
|
||||
final connected =
|
||||
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
||||
if (connected != _mediaConnected) {
|
||||
_mediaConnected = connected;
|
||||
_notifyInfo();
|
||||
if (connected) {
|
||||
if (role == CallRole.joiner || _topology == 'SERVER') {
|
||||
_setState(CallSessionState.active);
|
||||
}
|
||||
unawaited(_resolvePath());
|
||||
unawaited(_collectReceivers());
|
||||
}
|
||||
}
|
||||
if (s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
||||
if ((s == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||
s == RTCPeerConnectionState.RTCPeerConnectionStateClosed) &&
|
||||
_topology != 'SERVER') {
|
||||
_end();
|
||||
}
|
||||
};
|
||||
return pc;
|
||||
}
|
||||
|
||||
if (role == CallRole.caller) {
|
||||
_setState(CallSessionState.ringing);
|
||||
await _createAndSendOffer();
|
||||
Future<void> _addLocalMedia(RTCPeerConnection pc) async {
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': _wantVideo,
|
||||
});
|
||||
for (final track in _localStream!.getTracks()) {
|
||||
await pc.addTrack(track, _localStream!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setupSfu() async {
|
||||
if (_pc != null) {
|
||||
await _pc!.close();
|
||||
_pc = null;
|
||||
_remoteDescSet = false;
|
||||
_pendingCandidates.clear();
|
||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||
await track.stop();
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
_localStream = null;
|
||||
_videoSender = null;
|
||||
await _disposeLocalVideoStream();
|
||||
_localVideo = false;
|
||||
_localScreen = false;
|
||||
}
|
||||
_setState(CallSessionState.connecting);
|
||||
final pc = await _createPc(_iceServers);
|
||||
_pc = pc;
|
||||
await _addLocalMedia(pc);
|
||||
logger.t('[call][sfu] allocate-consumer');
|
||||
await _signaling?.allocateConsumer();
|
||||
}
|
||||
|
||||
Future<void> _onTopologyChanged(Map<String, dynamic> msg) async {
|
||||
final topo = msg['topology']?.toString();
|
||||
if (topo == null) return;
|
||||
logger.t('[call] topology-changed -> $topo');
|
||||
info.topology = topo;
|
||||
final switchingToSfu = topo == 'SERVER' && _topology != 'SERVER';
|
||||
_topology = topo;
|
||||
_notifyInfo();
|
||||
if (switchingToSfu) await _setupSfu();
|
||||
}
|
||||
|
||||
Future<void> _onProducerUpdated(Map<String, dynamic> msg) async {
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
|
||||
final session = msg['sessionId'];
|
||||
if (session != null) _sfuSessionId = session;
|
||||
|
||||
final description = msg['description'];
|
||||
String? sdp;
|
||||
var type = 'offer';
|
||||
if (description is Map) {
|
||||
sdp = (description['sdp'] ?? description['description']) as String?;
|
||||
type = (description['type'] as String?) ?? 'offer';
|
||||
} else if (description is String) {
|
||||
sdp = description;
|
||||
}
|
||||
if (sdp == null) {
|
||||
logger.t('[call][sfu] producer-updated without sdp: $msg');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.t('[call][sfu] producer offer: ${_mLines(sdp)} m-lines');
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdp, type));
|
||||
_remoteDescSet = true;
|
||||
await _flushCandidates();
|
||||
|
||||
final answer = await pc.createAnswer({});
|
||||
await pc.setLocalDescription(answer);
|
||||
await _waitIceGathering(pc, const Duration(seconds: 3));
|
||||
|
||||
final local = await pc.getLocalDescription();
|
||||
final answerSdp = local?.sdp ?? answer.sdp ?? '';
|
||||
final ssrcs = _extractSsrcs(answerSdp);
|
||||
logger.t('[call][sfu] answer: ${_mLines(answerSdp)} m-lines, '
|
||||
'ssrcs=${ssrcs.length}');
|
||||
|
||||
await _signaling?.acceptProducer(
|
||||
description: answerSdp,
|
||||
ssrcs: ssrcs,
|
||||
sessionId: _sfuSessionId,
|
||||
);
|
||||
|
||||
if (_wantVideo) await _publishCamera();
|
||||
unawaited(_collectReceivers());
|
||||
}
|
||||
|
||||
Future<void> _publishCamera() async {
|
||||
try {
|
||||
await _signaling?.changeSimulcast(
|
||||
mediaSource: 'CAMERA',
|
||||
layers: const [
|
||||
{'rid': 'h', 'width': 1280, 'height': 720, 'fps': 30, 'bitrateKbps': 2000},
|
||||
],
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
int _mLines(String sdp) => RegExp(r'^m=', multiLine: true).allMatches(sdp).length;
|
||||
|
||||
List<int> _extractSsrcs(String sdp) {
|
||||
final set = <int>{};
|
||||
for (final m in RegExp(r'^a=ssrc:(\d+)', multiLine: true).allMatches(sdp)) {
|
||||
final v = int.tryParse(m.group(1) ?? '');
|
||||
if (v != null) set.add(v);
|
||||
}
|
||||
return set.toList();
|
||||
}
|
||||
|
||||
Future<void> _waitIceGathering(
|
||||
RTCPeerConnection pc, Duration timeout) async {
|
||||
if (pc.iceGatheringState ==
|
||||
RTCIceGatheringState.RTCIceGatheringStateComplete) {
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
Timer? timer;
|
||||
void finish() {
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
}
|
||||
|
||||
pc.onIceGatheringState = (state) {
|
||||
if (state == RTCIceGatheringState.RTCIceGatheringStateComplete) finish();
|
||||
};
|
||||
timer = Timer(timeout, finish);
|
||||
await completer.future;
|
||||
timer.cancel();
|
||||
pc.onIceGatheringState = null;
|
||||
}
|
||||
|
||||
String _videoDir(String sdp) {
|
||||
var inVideo = false;
|
||||
String? mline;
|
||||
@@ -389,6 +877,7 @@ class CallSession {
|
||||
}
|
||||
|
||||
void _onLocalCandidate(RTCIceCandidate candidate) {
|
||||
if (_topology == 'SERVER') return;
|
||||
final peerId = _peerId;
|
||||
if (peerId == null || candidate.candidate == null) return;
|
||||
_signaling?.transmitCandidate(
|
||||
@@ -406,7 +895,7 @@ class CallSession {
|
||||
_accepted = true;
|
||||
logger.t('[call] accepted');
|
||||
await _signaling?.acceptCall();
|
||||
await _signaling?.changeMediaSettings(isAudioEnabled: !_muted);
|
||||
await _sendMediaSettings();
|
||||
_setState(CallSessionState.active);
|
||||
}
|
||||
|
||||
@@ -415,20 +904,110 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> setMuted(bool muted) async {
|
||||
await _applyMuted(muted, announce: true);
|
||||
}
|
||||
|
||||
Future<void> _applyMuted(bool muted, {bool announce = false}) async {
|
||||
_muted = muted;
|
||||
for (final track in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
||||
track.enabled = !muted;
|
||||
}
|
||||
await _signaling?.changeMediaSettings(isAudioEnabled: !muted);
|
||||
_notifyInfo();
|
||||
if (announce) await _sendMediaSettings();
|
||||
}
|
||||
|
||||
Future<void> hangup({String reason = 'HUNGUP'}) async {
|
||||
Future<void> _sendMediaSettings() async {
|
||||
await _signaling?.changeMediaSettings(
|
||||
isAudioEnabled: !_muted,
|
||||
isVideoEnabled: _localVideo,
|
||||
isScreenSharingEnabled: _localScreen,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setVideoEnabled(bool on) =>
|
||||
on ? _startLocalVideo(screen: false) : _stopLocalVideo();
|
||||
|
||||
Future<void> setScreenSharing(bool on) =>
|
||||
on ? _startLocalVideo(screen: true) : _stopLocalVideo();
|
||||
|
||||
Future<void> _startLocalVideo({required bool screen}) async {
|
||||
final pc = _pc;
|
||||
if (pc == null) return;
|
||||
|
||||
MediaStream stream;
|
||||
try {
|
||||
await _signaling?.hangup(reason: reason);
|
||||
stream = screen
|
||||
? await navigator.mediaDevices
|
||||
.getDisplayMedia(<String, dynamic>{'video': true, 'audio': false})
|
||||
: await navigator.mediaDevices
|
||||
.getUserMedia(<String, dynamic>{'video': true, 'audio': false});
|
||||
} catch (e) {
|
||||
logger.t('[call] video capture failed: $e');
|
||||
return;
|
||||
}
|
||||
|
||||
await _disposeLocalVideoStream();
|
||||
_localVideoStream = stream;
|
||||
|
||||
final tracks = stream.getVideoTracks();
|
||||
final track = tracks.isEmpty ? null : tracks.first;
|
||||
if (track != null) {
|
||||
if (_videoSender == null) {
|
||||
_videoSender = await pc.addTrack(track, stream);
|
||||
} else {
|
||||
await _videoSender!.replaceTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
_localVideo = !screen;
|
||||
_localScreen = screen;
|
||||
|
||||
if (_topology != 'SERVER') await _createAndSendOffer();
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
Future<void> _stopLocalVideo() async {
|
||||
try {
|
||||
await _videoSender?.replaceTrack(null);
|
||||
} catch (_) {}
|
||||
await _disposeLocalVideoStream();
|
||||
_localVideo = false;
|
||||
_localScreen = false;
|
||||
await _sendMediaSettings();
|
||||
_notifyInfo();
|
||||
}
|
||||
|
||||
Future<void> _disposeLocalVideoStream() async {
|
||||
final stream = _localVideoStream;
|
||||
_localVideoStream = null;
|
||||
if (stream == null) return;
|
||||
for (final track in stream.getTracks()) {
|
||||
try {
|
||||
await track.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await stream.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> hangup({String? reason}) async {
|
||||
final r = reason ?? _autoHangupReason();
|
||||
try {
|
||||
await _signaling?.hangup(reason: r);
|
||||
} catch (_) {}
|
||||
_end();
|
||||
}
|
||||
|
||||
String _autoHangupReason() {
|
||||
if (_current != CallSessionState.active) {
|
||||
if (role == CallRole.caller) return 'CANCELED';
|
||||
if (role == CallRole.callee && !_accepted) return 'REJECTED';
|
||||
}
|
||||
return 'HUNGUP';
|
||||
}
|
||||
|
||||
bool _ended = false;
|
||||
void _end() {
|
||||
if (_ended) return;
|
||||
@@ -438,10 +1017,12 @@ class CallSession {
|
||||
}
|
||||
|
||||
Future<void> _dispose() async {
|
||||
_levelTimer?.cancel();
|
||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
||||
await track.stop();
|
||||
}
|
||||
await _localStream?.dispose();
|
||||
await _disposeLocalVideoStream();
|
||||
await _pc?.close();
|
||||
if (_ownRemoteStream) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user