борт управления самолетом

This commit is contained in:
Jganenokk
2026-08-22 14:09:18 +07:00
parent fc66ba84c7
commit c3027a82fb
15 changed files with 1066 additions and 13 deletions
+89
View File
@@ -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;
}
}
+167 -7
View File
@@ -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;
+168
View File
@@ -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)';
}
+26
View File
@@ -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);
}
+26
View File
@@ -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);
}
+11
View File
@@ -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),
);
}
}
+30 -6
View File
@@ -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
+10
View File
@@ -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",
+60
View File
@@ -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:
+35
View File
@@ -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';
+35
View File
@@ -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 => 'Клиент';
+10
View File
@@ -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": "Страна",
+5
View File
@@ -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,
+106
View File
@@ -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), <String, dynamic>{
'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<String, dynamic>;
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);
});
}