борт управления самолетом
This commit is contained in:
@@ -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<List<AudioInputDevice>> microphones() async {
|
||||
try {
|
||||
final devices = await navigator.mediaDevices.enumerateDevices();
|
||||
final mics = <AudioInputDevice>[];
|
||||
final seen = <String>{};
|
||||
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<void> 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 = <String, dynamic>{};
|
||||
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<String?> 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<void>.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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<void>? _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 = <int>{};
|
||||
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<void> _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<void> _selectMicInsideEngine() async {
|
||||
final deviceId = _micDeviceId;
|
||||
if (deviceId == null || !AudioDevices.switchesInsideEngine) return;
|
||||
await AudioDevices.selectInput(deviceId);
|
||||
}
|
||||
|
||||
Future<void> _disposeMicStream() async {
|
||||
final stream = _micStream;
|
||||
_micStream = null;
|
||||
await _disposeStream(stream);
|
||||
}
|
||||
|
||||
List<MediaStreamTrack> get _audioTracks =>
|
||||
_micStream?.getAudioTracks() ??
|
||||
_localStream?.getAudioTracks() ??
|
||||
const <MediaStreamTrack>[];
|
||||
|
||||
void _applyAudioTracks() {
|
||||
for (final track in _audioTracks) {
|
||||
track.enabled = audioTransmitting;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> _resetMicRoute() async {
|
||||
_pulseSource = null;
|
||||
_monitorCapture = false;
|
||||
_micDeviceId = AppMicrophone.deviceId;
|
||||
await PulseAudio.closeBridge();
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> _replaceMicTrack() async {
|
||||
await _prepareMicRoute();
|
||||
final sender = _audioSender;
|
||||
if (sender == null) return;
|
||||
final stream = await navigator.mediaDevices.getUserMedia(<String, dynamic>{
|
||||
'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 <MediaStreamTrack>[]) {
|
||||
try {
|
||||
await old.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> _applyMuted(bool muted, {bool announce = false}) async {
|
||||
_muted = muted;
|
||||
for (final track
|
||||
in _localStream?.getAudioTracks() ?? <MediaStreamTrack>[]) {
|
||||
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;
|
||||
|
||||
@@ -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<ProcessResult?> _pactl(List<String> 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<bool> isAvailable() async =>
|
||||
(await _pactl(const ['info']))?.exitCode == 0;
|
||||
|
||||
static Future<List<PulseSource>> 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<PulseSource> parseSources(String json) {
|
||||
final List<dynamic> entries;
|
||||
try {
|
||||
entries = jsonDecode(json) as List<dynamic>;
|
||||
} catch (e) {
|
||||
logger.w('[call][pulse] список источников: $e');
|
||||
return const [];
|
||||
}
|
||||
final sources = <PulseSource>[];
|
||||
for (final entry in entries.whereType<Map<String, dynamic>>()) {
|
||||
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<PulseSource?> find(String name) async {
|
||||
for (final source in await sources()) {
|
||||
if (source.name == name) return source;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> 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<void> closeBridge() async {
|
||||
final module = _bridgeModule;
|
||||
_bridgeModule = null;
|
||||
_bridgeMaster = null;
|
||||
_bridgeSource = null;
|
||||
if (module == null) return;
|
||||
await _pactl(['unload-module', module]);
|
||||
}
|
||||
|
||||
static Future<void> _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<String, dynamic> 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)';
|
||||
}
|
||||
@@ -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<String>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getString(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setString(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<String> get current => _setting.current;
|
||||
|
||||
static String? get deviceId =>
|
||||
_setting.current.value.isEmpty ? null : _setting.current.value;
|
||||
|
||||
static Future<String> load() => _setting.load();
|
||||
|
||||
static Future<void> save(String value) => _setting.save(value);
|
||||
}
|
||||
@@ -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<String>(
|
||||
prefKey: prefKey,
|
||||
defaultValue: defaultValue,
|
||||
read: (prefs, key) => prefs.getString(key),
|
||||
write: (prefs, key, value) async {
|
||||
await prefs.setString(key, value);
|
||||
},
|
||||
);
|
||||
|
||||
static ValueNotifier<String> get current => _setting.current;
|
||||
|
||||
static String? get name =>
|
||||
_setting.current.value.isEmpty ? null : _setting.current.value;
|
||||
|
||||
static Future<String> load() => _setting.load();
|
||||
|
||||
static Future<void> save(String value) => _setting.save(value);
|
||||
}
|
||||
@@ -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<String> args) {
|
||||
if (args.contains(flag)) enabled = true;
|
||||
}
|
||||
}
|
||||
@@ -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<void> showCallMicrophoneSheet(
|
||||
BuildContext context, {
|
||||
required CallSession session,
|
||||
required ColorScheme scheme,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
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<void> _load() async {
|
||||
final pulse = PulseAudio.supported && await PulseAudio.isAvailable()
|
||||
? await PulseAudio.sources()
|
||||
: const <PulseSource>[];
|
||||
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<void> _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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<CallScreen> 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<void>(
|
||||
@@ -1010,6 +1018,16 @@ class _CallScreenState extends State<CallScreen> 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<CallScreen> 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Клиент';
|
||||
|
||||
|
||||
@@ -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": "Страна",
|
||||
|
||||
@@ -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<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initKolibri();
|
||||
DebugTest.parse(args);
|
||||
CallNoMute.parse(args);
|
||||
_installLogCapture();
|
||||
VideoPlayerMediaKit.ensureInitialized(
|
||||
windows: true,
|
||||
@@ -226,6 +229,7 @@ void main(List<String> 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<String> args) async {
|
||||
themeScheduleFuture,
|
||||
messageActionsFuture,
|
||||
swipeBackFuture,
|
||||
microphoneFuture,
|
||||
pranksFuture,
|
||||
storiesFuture,
|
||||
commandsFuture,
|
||||
|
||||
Reference in New Issue
Block a user