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)'; }