// PTT Protocol (WebSocket + LiveKit): // // 1. connectToGroup(groupId, jwtToken, livekitUrl) // → WS /ws/groups/{groupId}?token={jwtToken} // → دریافت {"type":"livekit_token","token":"..."} از سرور // → اتصال به LiveKit با listener token (can_publish=false) // // 2. startSpeaking() // → ارسال {"type":"request_speak"} روی WS // → دریافت {"type":"speaker_granted","token":"..."} یا {"type":"speaker_busy","speaker":"..."} // → اگه granted: اتصال مجدد LiveKit با speaker token (can_publish=true) + روشن کردن میک // // 3. stopSpeaking() // → خاموش کردن میک // → ارسال {"type":"stop_speak"} روی WS // // Broadcasts از سرور: // {"type":"speaker","user_id":"..."} → کسی شروع به صحبت کرد // {"type":"speaker_released"} → خط آزاد شد // {"type":"presence","users":[...]} → لیست آنلاین‌ها import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:livekit_client/livekit_client.dart'; import '../config/app_config.dart'; enum PttState { idle, connected, speaking, receiving } class PttService { WebSocket? _ws; Room? _room; EventsListener? _listener; String? _livekitUrl; // completers برای دریافت async پیام‌های WS Completer? _listenerTokenCompleter; Completer? _speakGrantCompleter; final _stateCtrl = StreamController.broadcast(); final _speakerCtrl = StreamController.broadcast(); final _errorCtrl = StreamController.broadcast(); PttState _state = PttState.idle; String? _speakerName; Stream get stateStream => _stateCtrl.stream; Stream get speakerStream => _speakerCtrl.stream; Stream get errorStream => _errorCtrl.stream; PttState get currentState => _state; String? get currentSpeaker => _speakerName; void _setState(PttState s) { _state = s; _stateCtrl.add(s); } // ── اتصال به گروه ──────────────────────────────────────────────────────── Future connectToGroup( String groupId, String jwtToken, String livekitUrl, ) async { if (AppConfig.debug) { await Future.delayed(const Duration(milliseconds: 400)); _setState(PttState.connected); return true; } _livekitUrl = livekitUrl; try { final wsUrl = '${AppConfig.wsBaseUrl}/ws/groups/$groupId?token=$jwtToken'; _ws = await WebSocket.connect(wsUrl); _listenerTokenCompleter = Completer(); _ws!.listen( (data) { if (data is String) { final msg = jsonDecode(data) as Map; _handleWsMessage(msg); } }, onError: (_) { _listenerTokenCompleter?.complete(null); if (_state != PttState.idle) { _setState(PttState.idle); _errorCtrl.add('اتصال به سرور قطع شد'); } }, onDone: () { _listenerTokenCompleter?.complete(null); if (_state != PttState.idle) { _setState(PttState.idle); } }, cancelOnError: true, ); // منتظر دریافت listener token از سرور final listenerToken = await _listenerTokenCompleter!.future.timeout( const Duration(seconds: 10), onTimeout: () => null, ); _listenerTokenCompleter = null; if (listenerToken == null) { _errorCtrl.add('دریافت توکن ناموفق بود'); return false; } return await _connectLiveKit(livekitUrl, listenerToken); } catch (_) { _errorCtrl.add('اتصال به سرور برقرار نشد'); return false; } } // ── پردازش پیام‌های WebSocket ───────────────────────────────────────────── void _handleWsMessage(Map msg) { final type = msg['type'] as String?; switch (type) { case 'livekit_token': // اولین پیام بعد از اتصال: listener token if (_listenerTokenCompleter != null && !_listenerTokenCompleter!.isCompleted) { _listenerTokenCompleter!.complete(msg['token'] as String?); } case 'speaker_granted': // درخواست صحبت تایید شد if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) { _speakGrantCompleter!.complete(msg['token'] as String?); } case 'speaker_busy': // خط اشغاله if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) { _speakGrantCompleter!.complete(null); } _errorCtrl.add('خط اشغال است'); case 'speaker': // broadcast: کسی مشغول صحبت شد if (_state != PttState.speaking) { final userId = msg['user_id'] as String?; _speakerName = userId; _speakerCtrl.add(userId); _setState(PttState.receiving); } case 'speaker_released': // broadcast: خط آزاد شد if (_state == PttState.receiving) { _speakerName = null; _speakerCtrl.add(null); _setState(PttState.connected); } } } // ── اتصال به LiveKit ────────────────────────────────────────────────────── Future _connectLiveKit( String url, String token, { bool setConnectedState = true, }) async { try { await _listener?.dispose(); await _room?.disconnect(); _room = Room( roomOptions: const RoomOptions( adaptiveStream: false, dynacast: false, ), ); _listener = _room!.createListener(); _listener! ..on( (e) => _onSpeakersChanged(e.speakers), ) ..on((_) { if (_state != PttState.speaking) _setState(PttState.idle); }); await _room!.connect(url, token); await _room!.localParticipant?.setMicrophoneEnabled(false); if (setConnectedState) _setState(PttState.connected); return true; } catch (_) { _errorCtrl.add('اتصال به سرور صوتی برقرار نشد'); return false; } } // ── تشخیص متکلم ────────────────────────────────────────────────────────── void _onSpeakersChanged(List speakers) { if (_state == PttState.speaking) return; final remoteSpeakers = speakers.whereType().toList(); if (remoteSpeakers.isNotEmpty) { final p = remoteSpeakers.first; _speakerName = p.name.isNotEmpty ? p.name : p.identity; _speakerCtrl.add(_speakerName); _setState(PttState.receiving); } else if (_state == PttState.receiving) { _speakerName = null; _speakerCtrl.add(null); _setState(PttState.connected); } } // ── PTT: شروع صحبت ──────────────────────────────────────────────────────── Future startSpeaking() async { if (_state != PttState.connected) return; if (AppConfig.debug) { _setState(PttState.speaking); return; } _speakGrantCompleter = Completer(); _ws?.add(jsonEncode({'type': 'request_speak'})); try { final speakerToken = await _speakGrantCompleter!.future.timeout( const Duration(seconds: 5), onTimeout: () => null, ); if (speakerToken == null) return; // پیام خطا قبلاً emit شده // اتصال مجدد LiveKit با speaker token final ok = await _connectLiveKit( _livekitUrl!, speakerToken, setConnectedState: false, ); if (!ok) return; await _room?.localParticipant?.setMicrophoneEnabled(true); _setState(PttState.speaking); } catch (_) { _errorCtrl.add('خطا در فعال‌سازی میکروفون'); } finally { _speakGrantCompleter = null; } } // ── PTT: پایان صحبت ────────────────────────────────────────────────────── Future stopSpeaking() async { if (_state != PttState.speaking) return; if (AppConfig.debug) { _setState(PttState.connected); _debugSimulateIncoming(); return; } try { await _room?.localParticipant?.setMicrophoneEnabled(false); } catch (_) {} _ws?.add(jsonEncode({'type': 'stop_speak'})); _setState(PttState.connected); } // ── Debug helper ────────────────────────────────────────────────────────── Future _debugSimulateIncoming() async { await Future.delayed(const Duration(milliseconds: 800)); if (_state != PttState.connected) return; _speakerName = 'کاربر تست'; _speakerCtrl.add(_speakerName); _setState(PttState.receiving); await Future.delayed(const Duration(seconds: 2)); if (_state != PttState.receiving) return; _speakerName = null; _speakerCtrl.add(null); _setState(PttState.connected); } // ── قطع اتصال ──────────────────────────────────────────────────────────── Future disconnect() async { if (AppConfig.debug) { _setState(PttState.idle); return; } try { await _room?.localParticipant?.setMicrophoneEnabled(false); } catch (_) {} await _listener?.dispose(); await _room?.disconnect(); _room = null; _listener = null; await _ws?.close(); _ws = null; _setState(PttState.idle); } Future dispose() async { await disconnect(); await _stateCtrl.close(); await _speakerCtrl.close(); await _errorCtrl.close(); } }