From c3027a82fb345cf7e121bc85f4e1088f3a2ed97c Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 22 Aug 2026 14:06:58 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=BE=D1=80=D1=82=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B0=D0=BC?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/calls/audio_devices.dart | 89 ++++++ lib/core/calls/call_session.dart | 174 ++++++++++- lib/core/calls/pulse_audio.dart | 168 ++++++++++ lib/core/config/app_microphone.dart | 26 ++ lib/core/config/app_pulse_source.dart | 26 ++ lib/core/config/call_no_mute.dart | 11 + .../screens/calls/call_mic_sheet.dart | 288 ++++++++++++++++++ lib/frontend/screens/calls/call_screen.dart | 36 ++- lib/l10n/app_en.arb | 10 + lib/l10n/app_localizations.dart | 60 ++++ lib/l10n/app_localizations_en.dart | 35 +++ lib/l10n/app_localizations_ru.dart | 35 +++ lib/l10n/app_ru.arb | 10 + lib/main.dart | 5 + test/call_mic_selection_test.dart | 106 +++++++ 15 files changed, 1066 insertions(+), 13 deletions(-) create mode 100644 lib/core/calls/audio_devices.dart create mode 100644 lib/core/calls/pulse_audio.dart create mode 100644 lib/core/config/app_microphone.dart create mode 100644 lib/core/config/app_pulse_source.dart create mode 100644 lib/core/config/call_no_mute.dart create mode 100644 lib/frontend/screens/calls/call_mic_sheet.dart create mode 100644 test/call_mic_selection_test.dart diff --git a/lib/core/calls/audio_devices.dart b/lib/core/calls/audio_devices.dart new file mode 100644 index 0000000..35b0451 --- /dev/null +++ b/lib/core/calls/audio_devices.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../utils/logger.dart'; + +class AudioInputDevice { + const AudioInputDevice({required this.id, required this.label}); + + final String id; + final String label; +} + +class AudioDevices { + AudioDevices._(); + + static Future> microphones() async { + try { + final devices = await navigator.mediaDevices.enumerateDevices(); + final mics = []; + final seen = {}; + for (final device in devices) { + if (device.kind != 'audioinput') continue; + if (device.deviceId.isEmpty || !seen.add(device.deviceId)) continue; + mics.add( + AudioInputDevice(id: device.deviceId, label: device.label.trim()), + ); + } + return mics; + } catch (e) { + logger.w('[call] enumerateDevices: $e'); + return const []; + } + } + + static bool get switchesInsideEngine => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS); + + static Future selectInput(String deviceId) async { + try { + await Helper.selectAudioInput(deviceId); + } catch (e) { + logger.w('[call] selectAudioInput($deviceId): $e'); + } + } + + static Object micConstraints( + String? deviceId, { + bool monitorCapture = false, + }) { + final constraints = {}; + final hasDevice = deviceId != null && deviceId.isNotEmpty; + if (hasDevice && !switchesInsideEngine) { + if (kIsWeb) { + constraints['deviceId'] = deviceId; + } else { + constraints['optional'] = [ + {'sourceId': deviceId}, + ]; + } + } + if (monitorCapture) { + constraints['echoCancellation'] = true; + constraints['noiseSuppression'] = false; + constraints['autoGainControl'] = false; + constraints['highpassFilter'] = false; + } + return constraints.isEmpty ? true : constraints; + } + + static Future findDevice(String token, {int attempts = 1}) async { + for (var attempt = 0; attempt < attempts; attempt++) { + for (final device in await microphones()) { + if (device.id == token || + device.id.contains(token) || + device.label.contains(token)) { + return device.id; + } + } + if (attempt + 1 < attempts) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + return null; + } +} diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index 0eea9d6..3b0f559 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -5,12 +5,17 @@ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import '../config/app_microphone.dart'; +import '../config/app_pulse_source.dart'; +import '../config/call_no_mute.dart'; import '../utils/logger.dart'; import '../utils/parse.dart'; +import 'audio_devices.dart'; import 'call_admin.dart'; import 'call_bridge.dart'; import 'call_info.dart'; import 'conversation_params.dart'; +import 'pulse_audio.dart'; import 'sfu_data_channel.dart'; import 'ws2_signaling.dart'; @@ -71,6 +76,7 @@ class CallSession { Ws2Signaling? _signaling; RTCPeerConnection? _pc; MediaStream? _localStream; + MediaStream? _micStream; MediaStream? _remoteStreamRef; int? _peerId; @@ -101,8 +107,12 @@ class CallSession { bool _localScreen = false; MediaStream? _cameraStream; MediaStream? _screenStream; + RTCRtpSender? _audioSender; RTCRtpSender? _videoSender; RTCRtpSender? _screenSender; + String? _micDeviceId = AppMicrophone.deviceId; + String? _pulseSource = AppPulseSource.name; + bool _monitorCapture = false; Completer? _gatherDone; bool _gotConnection = false; @@ -208,6 +218,9 @@ class CallSession { bool get peerIsKomet => _peerIsKomet; bool get isMuted => _muted; + bool get audioTransmitting => !_muted || CallNoMute.enabled; + String? get micDeviceId => _micDeviceId; + String? get pulseSource => _pulseSource; bool get isSpeaker => _speakerOn; bool get peerMuted => _peerMuted; bool get peerVideo => _peerVideo; @@ -388,6 +401,7 @@ class CallSession { } catch (_) {} _pc = null; + _audioSender = null; _videoSender = null; _screenSender = null; _remoteDescSet = false; @@ -406,6 +420,7 @@ class CallSession { await _localStream?.dispose(); } catch (_) {} _localStream = null; + await _disposeMicStream(); await _disposeStream(_cameraStream); await _disposeStream(_screenStream); @@ -440,7 +455,7 @@ class CallSession { } final loud = {}; - if (!_muted && local > _speakLevelOn) loud.add(ws2Config.userId); + if (audioTransmitting && 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); @@ -878,16 +893,159 @@ class CallSession { Future _addLocalMedia(RTCPeerConnection pc) async { await _prepareAudioSession(); + await _disposeMicStream(); + try { + await _prepareMicRoute(); + } catch (e) { + logger.w( + '[call][pulse] маршрут недоступен, беру устройство по умолчанию: $e', + ); + await _resetMicRoute(); + } + await _selectMicInsideEngine(); _localStream = await navigator.mediaDevices.getUserMedia({ - 'audio': true, + 'audio': AudioDevices.micConstraints( + _micDeviceId, + monitorCapture: _monitorCapture, + ), 'video': _wantVideo, }); for (final track in _localStream!.getTracks()) { - await pc.addTrack(track, _localStream!); + final sender = await pc.addTrack(track, _localStream!); + if (track.kind == 'audio') _audioSender = sender; } + _applyAudioTracks(); await applyAudioRoute(); } + Future _selectMicInsideEngine() async { + final deviceId = _micDeviceId; + if (deviceId == null || !AudioDevices.switchesInsideEngine) return; + await AudioDevices.selectInput(deviceId); + } + + Future _disposeMicStream() async { + final stream = _micStream; + _micStream = null; + await _disposeStream(stream); + } + + List get _audioTracks => + _micStream?.getAudioTracks() ?? + _localStream?.getAudioTracks() ?? + const []; + + void _applyAudioTracks() { + for (final track in _audioTracks) { + track.enabled = audioTransmitting; + } + } + + Future setPulseSource(String? sourceName) async { + final previous = _pulseSource; + final next = (sourceName == null || sourceName.isEmpty) ? null : sourceName; + _pulseSource = next; + if (next == null) await _resetMicRoute(); + try { + await _replaceMicTrack(); + } catch (e) { + _pulseSource = previous; + await _resetMicRoute(); + rethrow; + } + await AppPulseSource.save(next ?? ''); + _notifyInfo(); + } + + Future _resetMicRoute() async { + _pulseSource = null; + _monitorCapture = false; + _micDeviceId = AppMicrophone.deviceId; + await PulseAudio.closeBridge(); + } + + Future _prepareMicRoute() async { + final wanted = _pulseSource; + if (!PulseAudio.supported || wanted == null) { + _monitorCapture = false; + await PulseAudio.closeBridge(); + return; + } + final source = await PulseAudio.find(wanted); + if (source == null) { + logger.w('[call][pulse] источник $wanted пропал'); + await _resetMicRoute(); + return; + } + _monitorCapture = source.isMonitor; + if (!source.isMonitor) { + final direct = await AudioDevices.findDevice(source.name); + if (direct != null) { + _micDeviceId = direct; + await PulseAudio.closeBridge(); + return; + } + } + final bridge = await PulseAudio.openBridge(source.name); + final device = bridge == null + ? null + : await AudioDevices.findDevice(bridge, attempts: 8); + if (device == null) { + await PulseAudio.closeBridge(); + throw PulseRouteException(source.label); + } + _micDeviceId = device; + } + + Future setMicrophone(String? deviceId) async { + final next = (deviceId == null || deviceId.isEmpty) ? null : deviceId; + _micDeviceId = next; + _pulseSource = null; + _monitorCapture = false; + await AppMicrophone.save(next ?? ''); + await AppPulseSource.save(''); + await PulseAudio.closeBridge(); + if (AudioDevices.switchesInsideEngine) { + await _selectMicInsideEngine(); + } else { + await _replaceMicTrack(); + } + _notifyInfo(); + } + + Future _replaceMicTrack() async { + await _prepareMicRoute(); + final sender = _audioSender; + if (sender == null) return; + final stream = await navigator.mediaDevices.getUserMedia({ + 'audio': AudioDevices.micConstraints( + _micDeviceId, + monitorCapture: _monitorCapture, + ), + 'video': false, + }); + final tracks = stream.getAudioTracks(); + if (tracks.isEmpty) { + await _disposeStream(stream); + return; + } + final track = tracks.first; + track.enabled = audioTransmitting; + await sender.replaceTrack(track); + final previous = _micStream; + _micStream = stream; + if (previous != null) { + await _disposeStream(previous); + } else { + for (final old + in _localStream?.getAudioTracks() ?? const []) { + try { + await old.stop(); + } catch (_) {} + } + } + } + Future setSpeaker(bool on) async { if (_speakerOn == on) return; _speakerOn = on; @@ -1205,6 +1363,8 @@ class CallSession { } await _localStream?.dispose(); _localStream = null; + await _disposeMicStream(); + _audioSender = null; _videoSender = null; _screenSender = null; } @@ -1231,6 +1391,7 @@ class CallSession { await _pc?.close(); } catch (_) {} _pc = null; + _audioSender = null; _videoSender = null; _screenSender = null; _remoteDescSet = false; @@ -1953,10 +2114,7 @@ class CallSession { Future _applyMuted(bool muted, {bool announce = false}) async { _muted = muted; - for (final track - in _localStream?.getAudioTracks() ?? []) { - track.enabled = !muted; - } + _applyAudioTracks(); _notifyInfo(); if (announce) await _sendMediaSettings(); } @@ -2171,6 +2329,8 @@ class CallSession { await track.stop(); } await _localStream?.dispose(); + await _disposeMicStream(); + await PulseAudio.closeBridge(); await _disposeStream(_cameraStream); await _disposeStream(_screenStream); _cameraStream = null; diff --git a/lib/core/calls/pulse_audio.dart b/lib/core/calls/pulse_audio.dart new file mode 100644 index 0000000..0c3b468 --- /dev/null +++ b/lib/core/calls/pulse_audio.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart' show kIsWeb; + +import '../utils/logger.dart'; + +class PulseSource { + const PulseSource({ + required this.name, + required this.label, + required this.isMonitor, + }); + + final String name; + final String label; + final bool isMonitor; +} + +class PulseRouteException implements Exception { + const PulseRouteException(this.source); + + final String source; + + @override + String toString() => source; +} + +class PulseAudio { + PulseAudio._(); + + static const String bridgePrefix = 'komet_capture_'; + + static String? _bridgeModule; + static String? _bridgeMaster; + static String? _bridgeSource; + + static bool get supported => !kIsWeb && Platform.isLinux; + + static String get _bridgeName => '$bridgePrefix$pid'; + + static Future _pactl(List args) async { + if (!supported) return null; + try { + return await Process.run( + 'pactl', + args, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + } on ProcessException catch (e) { + logger.w('[call][pulse] pactl ${args.first}: ${e.message}'); + return null; + } + } + + static Future isAvailable() async => + (await _pactl(const ['info']))?.exitCode == 0; + + static Future> sources() async { + final result = await _pactl(const ['-f', 'json', 'list', 'sources']); + if (result == null || result.exitCode != 0) return const []; + return parseSources(result.stdout as String); + } + + static List parseSources(String json) { + final List entries; + try { + entries = jsonDecode(json) as List; + } catch (e) { + logger.w('[call][pulse] список источников: $e'); + return const []; + } + final sources = []; + for (final entry in entries.whereType>()) { + final name = entry['name']; + if (name is! String || name.isEmpty) continue; + if (name.startsWith(bridgePrefix)) continue; + final monitorOf = entry['monitor_source']; + final isMonitor = monitorOf is String && monitorOf.isNotEmpty; + sources.add( + PulseSource( + name: name, + label: _labelOf(entry, name, isMonitor), + isMonitor: isMonitor, + ), + ); + } + return sources; + } + + static Future find(String name) async { + for (final source in await sources()) { + if (source.name == name) return source; + } + return null; + } + + static Future openBridge(String master) async { + if (_bridgeMaster == master && _bridgeSource != null) return _bridgeSource; + await closeBridge(); + await _dropStaleBridges(); + final name = _bridgeName; + final result = await _pactl([ + 'load-module', + 'module-remap-source', + 'master=$master', + 'source_name=$name', + 'source_properties=device.description=$name', + ]); + if (result == null || result.exitCode != 0) { + logger.w('[call][pulse] remap-source($master): ${result?.stderr}'); + return null; + } + final module = (result.stdout as String).trim(); + if (module.isEmpty) return null; + _bridgeModule = module; + _bridgeMaster = master; + _bridgeSource = name; + logger.i('[call][pulse] мост $name ← $master (модуль $module)'); + return name; + } + + static Future closeBridge() async { + final module = _bridgeModule; + _bridgeModule = null; + _bridgeMaster = null; + _bridgeSource = null; + if (module == null) return; + await _pactl(['unload-module', module]); + } + + static Future _dropStaleBridges() async { + final result = await _pactl(const ['list', 'modules', 'short']); + if (result == null || result.exitCode != 0) return; + for (final line in const LineSplitter().convert(result.stdout as String)) { + final columns = line.split('\t'); + if (columns.length < 3) continue; + final owner = _bridgeOwnerPid(columns[2]); + if (owner == null || Directory('/proc/$owner').existsSync()) continue; + logger.i('[call][pulse] снимаю зависший мост процесса $owner'); + await _pactl(['unload-module', columns[0]]); + } + } + + static int? _bridgeOwnerPid(String argument) { + final match = RegExp('$bridgePrefix([0-9]+)').firstMatch(argument); + return match == null ? null : int.tryParse(match.group(1)!); + } + + static String _labelOf( + Map entry, + String name, + bool isMonitor, + ) { + final description = entry['description']; + if (_usable(description)) return description as String; + final properties = entry['properties']; + final device = properties is Map ? properties['device.description'] : null; + if (_usable(device)) { + return isMonitor ? 'Monitor of $device' : device as String; + } + return name; + } + + static bool _usable(Object? value) => + value is String && value.isNotEmpty && value != '(null)'; +} diff --git a/lib/core/config/app_microphone.dart b/lib/core/config/app_microphone.dart new file mode 100644 index 0000000..ad696da --- /dev/null +++ b/lib/core/config/app_microphone.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppMicrophone { + static const prefKey = 'call_microphone_id'; + static const String defaultValue = ''; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get deviceId => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} diff --git a/lib/core/config/app_pulse_source.dart b/lib/core/config/app_pulse_source.dart new file mode 100644 index 0000000..e4a349c --- /dev/null +++ b/lib/core/config/app_pulse_source.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppPulseSource { + static const prefKey = 'call_pulse_source'; + static const String defaultValue = ''; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getString(key), + write: (prefs, key, value) async { + await prefs.setString(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static String? get name => + _setting.current.value.isEmpty ? null : _setting.current.value; + + static Future load() => _setting.load(); + + static Future save(String value) => _setting.save(value); +} diff --git a/lib/core/config/call_no_mute.dart b/lib/core/config/call_no_mute.dart new file mode 100644 index 0000000..4a5f414 --- /dev/null +++ b/lib/core/config/call_no_mute.dart @@ -0,0 +1,11 @@ +class CallNoMute { + CallNoMute._(); + + static const String flag = '--no-mute'; + + static bool enabled = const bool.fromEnvironment('NO_MUTE'); + + static void parse(List args) { + if (args.contains(flag)) enabled = true; + } +} diff --git a/lib/frontend/screens/calls/call_mic_sheet.dart b/lib/frontend/screens/calls/call_mic_sheet.dart new file mode 100644 index 0000000..84020b8 --- /dev/null +++ b/lib/frontend/screens/calls/call_mic_sheet.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/calls/audio_devices.dart'; +import '../../../core/calls/call_session.dart'; +import '../../../core/calls/pulse_audio.dart'; +import '../../../core/config/app_fonts.dart'; +import '../../../core/config/call_no_mute.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/sheet_helpers.dart'; + +Future showCallMicrophoneSheet( + BuildContext context, { + required CallSession session, + required ColorScheme scheme, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: scheme.surfaceContainerHigh, + shape: kSheetShape, + builder: (_) => Theme( + data: Theme.of(context).copyWith(colorScheme: scheme), + child: _MicrophoneSheet(session: session), + ), + ); +} + +class _MicOption { + const _MicOption({ + required this.id, + required this.label, + this.detail, + this.isMonitor = false, + this.isDevice = false, + }); + + final String id; + final String label; + final String? detail; + final bool isMonitor; + final bool isDevice; +} + +class _MicrophoneSheet extends StatefulWidget { + final CallSession session; + + const _MicrophoneSheet({required this.session}); + + @override + State<_MicrophoneSheet> createState() => _MicrophoneSheetState(); +} + +class _MicrophoneSheetState extends State<_MicrophoneSheet> { + List<_MicOption>? _options; + bool _pulseMode = false; + String? _selected; + bool _switching = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final pulse = PulseAudio.supported && await PulseAudio.isAvailable() + ? await PulseAudio.sources() + : const []; + final devices = await AudioDevices.microphones(); + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final routed = pulse.map((source) => source.name).toSet(); + setState(() { + _pulseMode = pulse.isNotEmpty; + _selected = widget.session.pulseSource ?? widget.session.micDeviceId; + _options = [ + for (final source in pulse) + _MicOption( + id: source.name, + label: source.label, + detail: source.name, + isMonitor: source.isMonitor, + ), + for (var i = 0; i < devices.length; i++) + if (!routed.contains(devices[i].id)) + _MicOption( + id: devices[i].id, + label: devices[i].label.isNotEmpty + ? devices[i].label + : l10n.callMicrophoneFallback(i + 1), + isDevice: true, + ), + ]; + }); + } + + Future _select(String? id, {required bool viaDevice}) async { + if (_switching || id == _selected) return; + final l10n = AppLocalizations.of(context)!; + setState(() => _switching = true); + try { + if (_pulseMode && !viaDevice) { + await widget.session.setPulseSource(id); + } else { + await widget.session.setMicrophone(id); + } + if (mounted) setState(() => _selected = id); + } catch (e) { + if (mounted) { + showCustomNotification(context, l10n.callMicrophoneFailed(e)); + } + } finally { + if (mounted) setState(() => _switching = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final options = _options; + final inputs = options?.where((o) => !o.isMonitor).toList() ?? const []; + final monitors = options?.where((o) => o.isMonitor).toList() ?? const []; + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(cs, l10n), + if (CallNoMute.enabled) _noMuteHint(cs, l10n), + if (options == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else + Flexible( + child: ListView( + shrinkWrap: true, + padding: EdgeInsets.zero, + children: [ + _tile( + cs, + id: null, + label: l10n.callMicrophoneSystem, + icon: Symbols.settings_voice, + viaDevice: true, + ), + if (options.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Text( + l10n.callMicrophoneEmpty, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + ), + for (final option in inputs) + _tile( + cs, + id: option.id, + label: option.label, + detail: option.detail, + icon: Symbols.mic, + viaDevice: option.isDevice, + ), + if (monitors.isNotEmpty) ...[ + _group(cs, l10n.callMicrophoneMonitors), + for (final option in monitors) + _tile( + cs, + id: option.id, + label: option.label, + detail: option.detail, + icon: Symbols.graphic_eq, + viaDevice: option.isDevice, + ), + ], + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _header(ColorScheme cs, AppLocalizations l10n) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 8, 12), + child: Row( + children: [ + Expanded( + child: Text( + l10n.callMicrophoneTitle, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: displayFontOf(context), + ), + ), + ), + IconButton( + onPressed: () { + setState(() => _options = null); + _load(); + }, + tooltip: l10n.callMicrophoneRefresh, + icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), + ), + ], + ), + ); + + Widget _noMuteHint(ColorScheme cs, AppLocalizations l10n) => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: Row( + children: [ + Icon(Symbols.graphic_eq, size: 18, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.callNoMuteHint, + style: TextStyle(color: cs.primary, fontSize: 13), + ), + ), + ], + ), + ); + + Widget _group(ColorScheme cs, String title) => Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 6), + child: Text( + title, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); + + Widget _tile( + ColorScheme cs, { + required String? id, + required String label, + required IconData icon, + String? detail, + bool viaDevice = false, + }) { + final selected = _selected == id; + return ListTile( + leading: Icon(icon, color: selected ? cs.primary : cs.onSurface), + title: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? cs.primary : cs.onSurface, + fontSize: 16, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), + subtitle: detail == null + ? null + : Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + trailing: selected ? Icon(Symbols.check, color: cs.primary) : null, + enabled: !_switching, + onTap: () => _select(id, viaDevice: viaDevice), + ); + } +} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 467f40a..afea43c 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -5,11 +5,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' - show - MediaStream, - RTCVideoRenderer, - RTCVideoValue, - RTCVideoViewObjectFit; + show MediaStream, RTCVideoRenderer, RTCVideoValue, RTCVideoViewObjectFit; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; @@ -19,6 +15,7 @@ import '../../../core/calls/call_controller.dart'; import '../../../core/calls/call_info.dart'; import '../../../core/calls/call_session.dart'; import '../../../core/config/app_colors.dart'; +import '../../../core/config/call_no_mute.dart'; import '../../../core/utils/format.dart'; import '../../../l10n/app_localizations.dart'; import '../../widgets/call_video_view.dart'; @@ -27,6 +24,7 @@ import '../../widgets/glossy_pill.dart'; import '../../widgets/animated_slash_icon.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/small_spinner.dart'; +import 'call_mic_sheet.dart'; import 'call_participants_sheet.dart'; import 'komet_hub.dart'; import '../../../core/config/app_fonts.dart'; @@ -472,6 +470,16 @@ class _CallScreenState extends State with TickerProviderStateMixin { ); } + void _showMicrophones() { + final session = _session; + if (session == null) return; + showCallMicrophoneSheet( + context, + session: session, + scheme: _darkScheme(context), + ); + } + void _showInfoSheet() { final cs = _darkScheme(context); showModalBottomSheet( @@ -1010,6 +1018,16 @@ class _CallScreenState extends State with TickerProviderStateMixin { size: 26, ), ), + IconButton( + onPressed: _showMicrophones, + tooltip: l10n.callTooltipMicrophone, + icon: Icon( + Symbols.settings_voice, + color: cs.onSurface, + weight: 500, + size: 26, + ), + ), IconButton( onPressed: _showInfoSheet, tooltip: l10n.callInfoTitle, @@ -1295,10 +1313,13 @@ class _CallScreenState extends State with TickerProviderStateMixin { icon: Symbols.mic, slashedIcon: Symbols.mic_off, slashed: _isMuted, - label: _isMuted ? l10n.callUnmute : l10n.callMute, + label: _isMuted + ? (CallNoMute.enabled ? l10n.callMicStillLive : l10n.callUnmute) + : l10n.callMute, background: _isMuted ? cs.primary : cs.surfaceContainerHighest, foreground: _isMuted ? cs.onPrimary : cs.onSurface, onTap: _toggleMute, + onLongPress: _showMicrophones, ), _CallButton( icon: Symbols.call_end, @@ -1364,6 +1385,7 @@ class _CallButton extends StatelessWidget { final Color background; final Color foreground; final VoidCallback onTap; + final VoidCallback? onLongPress; final bool busy; const _CallButton({ @@ -1372,6 +1394,7 @@ class _CallButton extends StatelessWidget { required this.background, required this.foreground, required this.onTap, + this.onLongPress, this.slashedIcon, this.slashed = false, this.busy = false, @@ -1405,6 +1428,7 @@ class _CallButton extends StatelessWidget { color: background, borderRadius: BorderRadius.circular(31), onTap: busy ? null : onTap, + onLongPress: busy ? null : onLongPress, depth: 9, child: Center( child: busy diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2bf3de4..b6c0d76 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -403,6 +403,16 @@ "callMute": "Mute", "callEndButton": "End", "callCameraUnavailable": "Camera unavailable: {error}", + "callTooltipMicrophone": "Microphone", + "callMicrophoneTitle": "Microphone", + "callMicrophoneSystem": "System default", + "callMicrophoneEmpty": "No microphones found", + "callMicrophoneRefresh": "Refresh list", + "callMicrophoneMonitors": "Monitors — system audio", + "callMicrophoneFallback": "Microphone {index}", + "callMicrophoneFailed": "Could not switch microphone: {error}", + "callMicStillLive": "Still live", + "callNoMuteHint": "--no-mute: audio keeps going out even while the mic is off", "callInfoClient": "Client", "callInfoPlatform": "Platform", "callInfoCountry": "Country", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 428e500..28b5f18 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2180,6 +2180,66 @@ abstract class AppLocalizations { /// **'Camera unavailable: {error}'** String callCameraUnavailable(Object error); + /// No description provided for @callTooltipMicrophone. + /// + /// In en, this message translates to: + /// **'Microphone'** + String get callTooltipMicrophone; + + /// No description provided for @callMicrophoneTitle. + /// + /// In en, this message translates to: + /// **'Microphone'** + String get callMicrophoneTitle; + + /// No description provided for @callMicrophoneSystem. + /// + /// In en, this message translates to: + /// **'System default'** + String get callMicrophoneSystem; + + /// No description provided for @callMicrophoneEmpty. + /// + /// In en, this message translates to: + /// **'No microphones found'** + String get callMicrophoneEmpty; + + /// No description provided for @callMicrophoneRefresh. + /// + /// In en, this message translates to: + /// **'Refresh list'** + String get callMicrophoneRefresh; + + /// No description provided for @callMicrophoneMonitors. + /// + /// In en, this message translates to: + /// **'Monitors — system audio'** + String get callMicrophoneMonitors; + + /// No description provided for @callMicrophoneFallback. + /// + /// In en, this message translates to: + /// **'Microphone {index}'** + String callMicrophoneFallback(Object index); + + /// No description provided for @callMicrophoneFailed. + /// + /// In en, this message translates to: + /// **'Could not switch microphone: {error}'** + String callMicrophoneFailed(Object error); + + /// No description provided for @callMicStillLive. + /// + /// In en, this message translates to: + /// **'Still live'** + String get callMicStillLive; + + /// No description provided for @callNoMuteHint. + /// + /// In en, this message translates to: + /// **'--no-mute: audio keeps going out even while the mic is off'** + String get callNoMuteHint; + /// No description provided for @callInfoClient. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 95356ea..4d897a6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1107,6 +1107,41 @@ class AppLocalizationsEn extends AppLocalizations { return 'Camera unavailable: $error'; } + @override + String get callTooltipMicrophone => 'Microphone'; + + @override + String get callMicrophoneTitle => 'Microphone'; + + @override + String get callMicrophoneSystem => 'System default'; + + @override + String get callMicrophoneEmpty => 'No microphones found'; + + @override + String get callMicrophoneRefresh => 'Refresh list'; + + @override + String get callMicrophoneMonitors => 'Monitors — system audio'; + + @override + String callMicrophoneFallback(Object index) { + return 'Microphone $index'; + } + + @override + String callMicrophoneFailed(Object error) { + return 'Could not switch microphone: $error'; + } + + @override + String get callMicStillLive => 'Still live'; + + @override + String get callNoMuteHint => + '--no-mute: audio keeps going out even while the mic is off'; + @override String get callInfoClient => 'Client'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 15b6431..f6fb0d8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1109,6 +1109,41 @@ class AppLocalizationsRu extends AppLocalizations { return 'Камера недоступна: $error'; } + @override + String get callTooltipMicrophone => 'Микрофон'; + + @override + String get callMicrophoneTitle => 'Микрофон'; + + @override + String get callMicrophoneSystem => 'Системный по умолчанию'; + + @override + String get callMicrophoneEmpty => 'Микрофоны не найдены'; + + @override + String get callMicrophoneRefresh => 'Обновить список'; + + @override + String get callMicrophoneMonitors => 'Мониторы — звук системы'; + + @override + String callMicrophoneFallback(Object index) { + return 'Микрофон $index'; + } + + @override + String callMicrophoneFailed(Object error) { + return 'Не удалось переключить микрофон: $error'; + } + + @override + String get callMicStillLive => 'Всё равно слышно'; + + @override + String get callNoMuteHint => + '--no-mute: звук идёт даже с выключенным микрофоном'; + @override String get callInfoClient => 'Клиент'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9c39028..913432b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -361,6 +361,16 @@ "callMute": "Выкл. звук", "callEndButton": "Завершить", "callCameraUnavailable": "Камера недоступна: {error}", + "callTooltipMicrophone": "Микрофон", + "callMicrophoneTitle": "Микрофон", + "callMicrophoneSystem": "Системный по умолчанию", + "callMicrophoneEmpty": "Микрофоны не найдены", + "callMicrophoneRefresh": "Обновить список", + "callMicrophoneMonitors": "Мониторы — звук системы", + "callMicrophoneFallback": "Микрофон {index}", + "callMicrophoneFailed": "Не удалось переключить микрофон: {error}", + "callMicStillLive": "Всё равно слышно", + "callNoMuteHint": "--no-mute: звук идёт даже с выключенным микрофоном", "callInfoClient": "Клиент", "callInfoPlatform": "Платформа", "callInfoCountry": "Страна", diff --git a/lib/main.dart b/lib/main.dart index 8caa05c..75cb592 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,12 +26,14 @@ import 'core/config/app_show_extra_info.dart'; import 'core/config/app_spectrum_background.dart'; import 'core/config/app_bubble_behavior.dart'; import 'core/config/komet_settings.dart'; +import 'core/config/call_no_mute.dart'; import 'core/config/debug_test.dart'; import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/custom_font_service.dart'; import 'core/config/app_message_actions_style.dart'; +import 'core/config/app_microphone.dart'; import 'core/config/app_swipe_back_desktop.dart'; import 'core/config/app_pranks.dart'; import 'core/config/app_stories.dart'; @@ -180,6 +182,7 @@ void main(List args) async { WidgetsFlutterBinding.ensureInitialized(); await initKolibri(); DebugTest.parse(args); + CallNoMute.parse(args); _installLogCapture(); VideoPlayerMediaKit.ensureInitialized( windows: true, @@ -226,6 +229,7 @@ void main(List args) async { final themeScheduleFuture = AppThemeSchedule.load(); final messageActionsFuture = AppMessageActionsStyle.load(); final swipeBackFuture = AppSwipeBackDesktop.load(); + final microphoneFuture = AppMicrophone.load(); final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); final commandsFuture = AppCommands.load(); @@ -287,6 +291,7 @@ void main(List args) async { themeScheduleFuture, messageActionsFuture, swipeBackFuture, + microphoneFuture, pranksFuture, storiesFuture, commandsFuture, diff --git a/test/call_mic_selection_test.dart b/test/call_mic_selection_test.dart new file mode 100644 index 0000000..6d88633 --- /dev/null +++ b/test/call_mic_selection_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:komet/core/calls/audio_devices.dart'; +import 'package:komet/core/calls/pulse_audio.dart'; +import 'package:komet/core/config/call_no_mute.dart'; + +const _deviceId = 'mic-test-0'; + +const _sourcesJson = ''' +[ + { + "name": "alsa_input.test-device", + "description": "(null)", + "monitor_source": "", + "properties": {"device.description": "Тестовая звуковая карта"} + }, + { + "name": "alsa_output.test-device.monitor", + "description": "(null)", + "monitor_source": "alsa_output.test-device", + "properties": {"device.description": "Тестовая звуковая карта"} + }, + { + "name": "virtual_sink.monitor", + "description": "Monitor of Virtual Sink", + "monitor_source": "virtual_sink", + "properties": {} + }, + { + "name": "komet_capture_4242", + "description": "komet_capture_4242", + "monitor_source": "", + "properties": {} + } +] +'''; + +void main() { + tearDown(() { + debugDefaultTargetPlatformOverride = null; + CallNoMute.enabled = false; + }); + + test('--no-mute включается только своим флагом', () { + CallNoMute.enabled = false; + CallNoMute.parse(const ['--debug-test']); + expect(CallNoMute.enabled, isFalse); + + CallNoMute.parse(const ['--debug-test', '--no-mute']); + expect(CallNoMute.enabled, isTrue); + }); + + test('desktop выбирает вход через sourceId', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect(AudioDevices.switchesInsideEngine, isFalse); + expect(AudioDevices.micConstraints(_deviceId), { + 'optional': [ + {'sourceId': _deviceId}, + ], + }); + }); + + test('мобильные платформы переключают вход внутри движка', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(AudioDevices.switchesInsideEngine, isTrue); + expect(AudioDevices.micConstraints(_deviceId), isTrue); + }); + + test('без выбранного устройства ограничений нет', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect(AudioDevices.micConstraints(null), isTrue); + expect(AudioDevices.micConstraints(''), isTrue); + }); + + test('захват монитора глушит шумодав и АРУ, но оставляет эхоподавление', () { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + final constraints = + AudioDevices.micConstraints(_deviceId, monitorCapture: true) + as Map; + expect(constraints['optional'], [ + {'sourceId': _deviceId}, + ]); + expect(constraints['echoCancellation'], isTrue); + expect(constraints['noiseSuppression'], isFalse); + expect(constraints['autoGainControl'], isFalse); + expect(constraints['highpassFilter'], isFalse); + }); + + test('источники pulse разбираются вместе с мониторами', () { + final sources = PulseAudio.parseSources(_sourcesJson); + expect(sources.map((s) => s.name), [ + 'alsa_input.test-device', + 'alsa_output.test-device.monitor', + 'virtual_sink.monitor', + ]); + expect(sources[0].isMonitor, isFalse); + expect(sources[0].label, 'Тестовая звуковая карта'); + expect(sources[1].isMonitor, isTrue); + expect(sources[1].label, 'Monitor of Тестовая звуковая карта'); + expect(sources[2].label, 'Monitor of Virtual Sink'); + }); + + test('битый вывод pactl не роняет разбор', () { + expect(PulseAudio.parseSources('не json'), isEmpty); + }); +}