feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Loads available input/output audio devices.
|
||||
typedef AudioDeviceListLoader = Future<rust.BridgeAudioDeviceList> Function();
|
||||
|
||||
/// Persists a selected audio device name.
|
||||
typedef AudioDeviceSetter = Future<void> Function({String? name});
|
||||
|
||||
/// Which desktop audio device group this tile manages.
|
||||
enum AudioDeviceKind {
|
||||
/// Capture device.
|
||||
input,
|
||||
|
||||
/// Playback device.
|
||||
output,
|
||||
}
|
||||
|
||||
/// Desktop audio device selector tile.
|
||||
class AudioDeviceListTile extends StatefulWidget {
|
||||
/// Construct an audio device list tile.
|
||||
const AudioDeviceListTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.kind,
|
||||
AudioDeviceListLoader? loadDevices,
|
||||
AudioDeviceSetter? setInputDevice,
|
||||
AudioDeviceSetter? setOutputDevice,
|
||||
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
|
||||
setInputDevice = setInputDevice ?? rust.setInputDevice,
|
||||
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
|
||||
|
||||
/// Tile title.
|
||||
final String label;
|
||||
|
||||
/// Device group managed by this tile.
|
||||
final AudioDeviceKind kind;
|
||||
|
||||
/// Loads available devices.
|
||||
final AudioDeviceListLoader loadDevices;
|
||||
|
||||
/// Selects an input device.
|
||||
final AudioDeviceSetter setInputDevice;
|
||||
|
||||
/// Selects an output device.
|
||||
final AudioDeviceSetter setOutputDevice;
|
||||
|
||||
@override
|
||||
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
|
||||
}
|
||||
|
||||
class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
|
||||
List<rust.BridgeAudioDevice> _devices = [];
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDevices();
|
||||
}
|
||||
|
||||
Future<void> _loadDevices() async {
|
||||
final list = await widget.loadDevices();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_devices = switch (widget.kind) {
|
||||
AudioDeviceKind.input => list.inputDevices,
|
||||
AudioDeviceKind.output => list.outputDevices,
|
||||
};
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectDevice(rust.BridgeAudioDevice device) async {
|
||||
switch (widget.kind) {
|
||||
case AudioDeviceKind.input:
|
||||
await widget.setInputDevice(name: device.name);
|
||||
case AudioDeviceKind.output:
|
||||
await widget.setOutputDevice(name: device.name);
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${widget.label} set to ${device.name}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded) {
|
||||
return ListTile(
|
||||
title: Text(widget.label),
|
||||
subtitle: const Text('Loading...'),
|
||||
trailing: const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_devices.isEmpty) {
|
||||
return ListTile(
|
||||
title: Text(widget.label),
|
||||
subtitle: const Text('System default'),
|
||||
leading: const Icon(Icons.check_circle_outline, size: 18),
|
||||
);
|
||||
}
|
||||
return ExpansionTile(
|
||||
title: Text(widget.label),
|
||||
subtitle: Text('${_devices.length} available'),
|
||||
leading: const Icon(Icons.headphones, size: 18),
|
||||
children: [
|
||||
for (final device in _devices)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(device.name, style: const TextStyle(fontSize: 13)),
|
||||
trailing: device.isDefault
|
||||
? const Icon(Icons.check, size: 16, color: Colors.green)
|
||||
: null,
|
||||
onTap: device.isDefault ? null : () => _selectDevice(device),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import 'dart:async' show StreamSubscription, unawaited;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:haptic_kit/haptic_kit.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/android_audio_output_devices.dart';
|
||||
|
||||
/// Tile that displays the active audio output route and opens the
|
||||
/// native picker on tap.
|
||||
class AudioOutputTile extends StatefulWidget {
|
||||
/// Construct an audio output tile.
|
||||
const AudioOutputTile({super.key});
|
||||
|
||||
@override
|
||||
State<AudioOutputTile> createState() => AudioOutputTileState();
|
||||
}
|
||||
|
||||
/// State for [AudioOutputTile].
|
||||
class AudioOutputTileState extends State<AudioOutputTile> {
|
||||
static const _androidOutputChannel = MethodChannel('app.audio_output');
|
||||
static const _androidOutputEvents = EventChannel('app.audio_output/events');
|
||||
|
||||
AVAudioSessionPortDescription? _activeOutput;
|
||||
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
|
||||
StreamSubscription<dynamic>? _androidOutputSub;
|
||||
List<AndroidAudioOutputDevice> _androidDevices = const [];
|
||||
bool _androidLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
_refreshAndroidDevices();
|
||||
_androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen((
|
||||
_,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
_refreshAndroidDevices();
|
||||
});
|
||||
return;
|
||||
}
|
||||
_refresh();
|
||||
_routeSub = AVAudioSession().routeChangeStream.listen((_) {
|
||||
if (!mounted) return;
|
||||
_refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routeSub?.cancel();
|
||||
_routeSub = null;
|
||||
_androidOutputSub?.cancel();
|
||||
_androidOutputSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshAndroidDevices() async {
|
||||
if (_androidLoading) return;
|
||||
setState(() => _androidLoading = true);
|
||||
try {
|
||||
final raw =
|
||||
await _androidOutputChannel.invokeListMethod<dynamic>(
|
||||
'getOutputDevices',
|
||||
) ??
|
||||
const [];
|
||||
final devices = parseAndroidAudioOutputDevices(raw);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_androidDevices = devices;
|
||||
_androidLoading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _androidLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
final route = await AVAudioSession().currentRoute;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_activeOutput = route.outputs.firstOrNull;
|
||||
});
|
||||
} catch (_) {
|
||||
// AVAudioSession may transiently throw before the session is active.
|
||||
}
|
||||
}
|
||||
|
||||
static String portLabel(
|
||||
AVAudioSessionPort? type,
|
||||
String fallback,
|
||||
AppL10n l10n,
|
||||
) {
|
||||
switch (type) {
|
||||
case AVAudioSessionPort.builtInSpeaker:
|
||||
return l10n.audioRouteSpeaker;
|
||||
case AVAudioSessionPort.builtInReceiver:
|
||||
return l10n.audioRouteReceiver;
|
||||
case AVAudioSessionPort.bluetoothHfp:
|
||||
case AVAudioSessionPort.bluetoothA2dp:
|
||||
case AVAudioSessionPort.bluetoothLe:
|
||||
return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback;
|
||||
case AVAudioSessionPort.headphones:
|
||||
case AVAudioSessionPort.headsetMic:
|
||||
return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback;
|
||||
case AVAudioSessionPort.carAudio:
|
||||
return l10n.audioRouteCarAudio;
|
||||
case AVAudioSessionPort.airPlay:
|
||||
return l10n.audioRouteAirplay;
|
||||
case AVAudioSessionPort.builtInMic:
|
||||
return fallback.isEmpty ? l10n.audioRouteReceiver : fallback;
|
||||
case null:
|
||||
default:
|
||||
return fallback.isEmpty ? l10n.audioRouteUnknown : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
static IconData portIcon(AVAudioSessionPort? type) {
|
||||
switch (type) {
|
||||
case AVAudioSessionPort.builtInSpeaker:
|
||||
return Icons.volume_up;
|
||||
case AVAudioSessionPort.builtInReceiver:
|
||||
return Icons.phone_in_talk;
|
||||
case AVAudioSessionPort.bluetoothHfp:
|
||||
case AVAudioSessionPort.bluetoothA2dp:
|
||||
case AVAudioSessionPort.bluetoothLe:
|
||||
return Icons.bluetooth_audio;
|
||||
case AVAudioSessionPort.headphones:
|
||||
case AVAudioSessionPort.headsetMic:
|
||||
return Icons.headset;
|
||||
case AVAudioSessionPort.carAudio:
|
||||
return Icons.directions_car;
|
||||
case AVAudioSessionPort.airPlay:
|
||||
return Icons.airplay;
|
||||
default:
|
||||
return Icons.speaker;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPicker() async {
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final selectedDeviceId = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _AndroidAudioOutputPickerSheet(
|
||||
devices: _androidDevices,
|
||||
loading: _androidLoading,
|
||||
onRefresh: _refreshAndroidDevices,
|
||||
),
|
||||
);
|
||||
if (selectedDeviceId == null) return;
|
||||
try {
|
||||
if (selectedDeviceId == 'auto') {
|
||||
await _androidOutputChannel.invokeMethod<void>(
|
||||
'clearCommunicationDevice',
|
||||
);
|
||||
} else {
|
||||
final changed = await _androidOutputChannel.invokeMethod<bool>(
|
||||
'setCommunicationDevice',
|
||||
{'deviceId': selectedDeviceId},
|
||||
);
|
||||
if (changed != true && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppL10n.of(context).audioRouteCannotSelect),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await _refreshAndroidDevices();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => const _AudioOutputPickerSheet(),
|
||||
);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final selected = selectedAndroidAudioOutputDevice(_androidDevices);
|
||||
final label = selected == null
|
||||
? l10n.audioRouteSystemDefault
|
||||
: _androidDeviceLabel(selected.type, selected.name, l10n);
|
||||
return _AudioRouteRow(
|
||||
icon: _androidDeviceIcon(selected?.type),
|
||||
label: _androidLoading ? '${l10n.audioRouteUnknown}…' : label,
|
||||
onTap: _openPicker,
|
||||
);
|
||||
}
|
||||
|
||||
final port = _activeOutput;
|
||||
final label = portLabel(port?.portType, port?.portName ?? '', l10n);
|
||||
return _AudioRouteRow(
|
||||
icon: portIcon(port?.portType),
|
||||
label: label,
|
||||
onTap: _openPicker,
|
||||
);
|
||||
}
|
||||
|
||||
static String _androidDeviceLabel(
|
||||
String type,
|
||||
String fallback,
|
||||
AppL10n l10n,
|
||||
) => switch (type) {
|
||||
'speaker' => l10n.audioRouteSpeaker,
|
||||
'earpiece' => l10n.audioRouteEarpiece,
|
||||
'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset,
|
||||
'bluetoothA2dp' ||
|
||||
'bluetoothSco' ||
|
||||
'bluetoothLe' => l10n.audioRouteBluetooth,
|
||||
'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback,
|
||||
'hdmi' => l10n.audioRouteCarAudio,
|
||||
_ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback,
|
||||
};
|
||||
|
||||
static IconData _androidDeviceIcon(String? type) => switch (type) {
|
||||
'speaker' => Icons.volume_up,
|
||||
'earpiece' => Icons.phone_in_talk,
|
||||
'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset,
|
||||
'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio,
|
||||
'hdmi' => Icons.tv,
|
||||
_ => Icons.speaker,
|
||||
};
|
||||
}
|
||||
|
||||
class _AudioRouteRow extends StatelessWidget {
|
||||
const _AudioRouteRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.audioOutputLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AndroidAudioOutputPickerSheet extends StatelessWidget {
|
||||
const _AndroidAudioOutputPickerSheet({
|
||||
required this.devices,
|
||||
required this.loading,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final List<AndroidAudioOutputDevice> devices;
|
||||
final bool loading;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
_PickerRow(
|
||||
icon: Icons.speaker,
|
||||
label: l10n.audioRouteSystemDefault,
|
||||
selected: !devices.any((d) => d.isSelected),
|
||||
onTap: () => Navigator.of(context).pop('auto'),
|
||||
),
|
||||
if (loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (devices.isEmpty)
|
||||
TextButton.icon(
|
||||
onPressed: onRefresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.audioRouteRefreshDevices),
|
||||
)
|
||||
else
|
||||
for (final device in devices)
|
||||
_PickerRow(
|
||||
icon: AudioOutputTileState._androidDeviceIcon(device.type),
|
||||
label: AudioOutputTileState._androidDeviceLabel(
|
||||
device.type,
|
||||
device.name,
|
||||
l10n,
|
||||
),
|
||||
selected: device.isSelected,
|
||||
onTap: device.isAvailableForCommunication
|
||||
? () => Navigator.of(context).pop(device.id)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioOutputPickerSheet extends StatefulWidget {
|
||||
const _AudioOutputPickerSheet();
|
||||
|
||||
@override
|
||||
State<_AudioOutputPickerSheet> createState() =>
|
||||
_AudioOutputPickerSheetState();
|
||||
}
|
||||
|
||||
class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
||||
Set<AVAudioSessionPortDescription> _availableInputs = const {};
|
||||
AVAudioSessionRouteDescription? _route;
|
||||
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
_routeSub = AVAudioSession().routeChangeStream.listen((_) {
|
||||
if (!mounted) return;
|
||||
_refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routeSub?.cancel();
|
||||
_routeSub = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
final session = AVAudioSession();
|
||||
final inputs = await session.availableInputs;
|
||||
final route = await session.currentRoute;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_availableInputs = inputs;
|
||||
_route = route;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectSpeaker() async {
|
||||
try {
|
||||
await AVAudioSession().overrideOutputAudioPort(
|
||||
AVAudioSessionPortOverride.speaker,
|
||||
);
|
||||
unawaited(Haptics.selection().catchError((_) {}));
|
||||
debugPrint('chanora: audio output -> speakerphone (override applied)');
|
||||
} catch (e, st) {
|
||||
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _selectReceiver() async {
|
||||
try {
|
||||
await AVAudioSession().overrideOutputAudioPort(
|
||||
AVAudioSessionPortOverride.none,
|
||||
);
|
||||
unawaited(Haptics.selection().catchError((_) {}));
|
||||
debugPrint('chanora: audio output -> receiver (override cleared)');
|
||||
} catch (e, st) {
|
||||
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _selectInput(AVAudioSessionPortDescription port) async {
|
||||
try {
|
||||
await AVAudioSession().overrideOutputAudioPort(
|
||||
AVAudioSessionPortOverride.none,
|
||||
);
|
||||
await AVAudioSession().setPreferredInput(port);
|
||||
unawaited(Haptics.selection().catchError((_) {}));
|
||||
debugPrint('chanora: audio output -> ${port.portType}');
|
||||
} catch (e, st) {
|
||||
debugPrint('chanora: _selectInput FAILED: $e\n$st');
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
if (_loading) {
|
||||
return const SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(40),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final currentOutputType = _route?.outputs.firstOrNull?.portType;
|
||||
final currentInputUid = _route?.inputs.firstOrNull?.uid;
|
||||
final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker;
|
||||
final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver;
|
||||
final externalInputs = _availableInputs
|
||||
.where((p) => p.portType != AVAudioSessionPort.builtInMic)
|
||||
.toList();
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
_PickerRow(
|
||||
icon: Icons.volume_up,
|
||||
label: l10n.audioRouteSpeaker,
|
||||
selected: isSpeaker,
|
||||
onTap: _selectSpeaker,
|
||||
),
|
||||
_PickerRow(
|
||||
icon: Icons.phone_in_talk,
|
||||
label: l10n.audioRouteReceiver,
|
||||
selected: isReceiver,
|
||||
onTap: _selectReceiver,
|
||||
),
|
||||
for (final port in externalInputs)
|
||||
_PickerRow(
|
||||
icon: AudioOutputTileState.portIcon(port.portType),
|
||||
label: AudioOutputTileState.portLabel(
|
||||
port.portType,
|
||||
port.portName,
|
||||
l10n,
|
||||
),
|
||||
selected: !isSpeaker && port.uid == currentInputUid,
|
||||
onTap: () => _selectInput(port),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PickerRow extends StatelessWidget {
|
||||
const _PickerRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final enabled = onTap != null;
|
||||
final color = selected
|
||||
? theme.colorScheme.primary
|
||||
: enabled
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant.withAlpha(130);
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
enabled: enabled,
|
||||
label: label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ExcludeSemantics(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(Icons.check, color: theme.colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Fallback audio-processing config used before the bridge can report one.
|
||||
const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig(
|
||||
route: rust.BridgeAudioRoute.unknown,
|
||||
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: rust.BridgeVadBackend.sileroOnnx,
|
||||
aec: rust.BridgeEffectOwner.platform,
|
||||
ns: rust.BridgeEffectOwner.platform,
|
||||
agc: rust.BridgeEffectOwner.platform,
|
||||
hpfEnabled: true,
|
||||
limiterEnabled: true,
|
||||
vadHangoverMs: 500,
|
||||
vadPreRollMs: 160,
|
||||
vadMinTxMs: 200,
|
||||
debugWavDumpEnabled: false,
|
||||
);
|
||||
|
||||
/// Mutable UI state for audio-processing controls.
|
||||
class AudioProcessingConfigState {
|
||||
/// Build control state from a bridge config.
|
||||
AudioProcessingConfigState.fromConfig(rust.BridgeAudioProcessingConfig config)
|
||||
: nsEnabled = config.ns != rust.BridgeEffectOwner.off,
|
||||
aecEnabled = config.aec != rust.BridgeEffectOwner.off,
|
||||
agcEnabled = config.agc != rust.BridgeEffectOwner.off,
|
||||
hpfEnabled = config.hpfEnabled,
|
||||
limiterEnabled = config.limiterEnabled,
|
||||
debugWavDump = config.debugWavDumpEnabled,
|
||||
preferHardware = _usesPlatformEffects(config),
|
||||
vadBackend = normalizedVadBackend(config.vadBackend),
|
||||
iosMode = config.iosMode;
|
||||
|
||||
/// Noise suppression toggle.
|
||||
bool nsEnabled;
|
||||
|
||||
/// Echo cancellation toggle.
|
||||
bool aecEnabled;
|
||||
|
||||
/// Automatic gain-control toggle.
|
||||
bool agcEnabled;
|
||||
|
||||
/// High-pass filter toggle.
|
||||
bool hpfEnabled;
|
||||
|
||||
/// Limiter toggle.
|
||||
bool limiterEnabled;
|
||||
|
||||
/// Debug WAV dump toggle.
|
||||
bool debugWavDump;
|
||||
|
||||
/// Android hardware effects preference.
|
||||
bool preferHardware;
|
||||
|
||||
/// Selected VAD backend.
|
||||
rust.BridgeVadBackend vadBackend;
|
||||
|
||||
/// Selected iOS processing mode.
|
||||
rust.BridgeIosVoiceProcessingMode iosMode;
|
||||
|
||||
/// Build the bridge config represented by this UI state.
|
||||
rust.BridgeAudioProcessingConfig buildConfig({
|
||||
required rust.BridgeAudioProcessingConfig base,
|
||||
bool? isAndroid,
|
||||
}) {
|
||||
final android = isAndroid ?? Platform.isAndroid;
|
||||
final vad = normalizedVadBackend(vadBackend);
|
||||
|
||||
if (android) {
|
||||
final owner = preferHardware
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: iosMode,
|
||||
processingBackend: preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: vad,
|
||||
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
final isSonora =
|
||||
iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
final aecOwner = isSonora
|
||||
? (aecEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform;
|
||||
final nsOwner = isSonora
|
||||
? (nsEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (agcEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: base.route,
|
||||
iosMode: iosMode,
|
||||
processingBackend: isSonora
|
||||
? rust.BridgeAudioBackend.webrtcApm
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vad,
|
||||
aec: aecOwner,
|
||||
ns: nsOwner,
|
||||
agc: agcOwner,
|
||||
hpfEnabled: hpfEnabled,
|
||||
limiterEnabled: limiterEnabled,
|
||||
vadHangoverMs: base.vadHangoverMs,
|
||||
vadPreRollMs: base.vadPreRollMs,
|
||||
vadMinTxMs: base.vadMinTxMs,
|
||||
debugWavDumpEnabled: debugWavDump,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Never leave the UI on the hidden disabled backend.
|
||||
rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) {
|
||||
return backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: backend;
|
||||
}
|
||||
|
||||
bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) {
|
||||
return config.aec == rust.BridgeEffectOwner.platform ||
|
||||
config.ns == rust.BridgeEffectOwner.platform ||
|
||||
config.agc == rust.BridgeEffectOwner.platform;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/link_trust_service.dart';
|
||||
import '../services/ts3_server_link.dart';
|
||||
|
||||
final _tagRe = RegExp(
|
||||
r'\[(\/?(?:b|i|u|s'
|
||||
@@ -13,12 +14,14 @@ final _tagRe = RegExp(
|
||||
r'))\]',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _colorRe = RegExp(r'color=([#\w]+)');
|
||||
final _sizeRe = RegExp(r'size=(\d+)');
|
||||
final _urlRe = RegExp(r'url=(.+)');
|
||||
final _urlRe = RegExp(r'url=(.+)', caseSensitive: false);
|
||||
final _imgRe = RegExp(r'img=(.+)');
|
||||
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
|
||||
final _urlAutoRe = RegExp(
|
||||
r'(?:\[url\])?((?:https?|ts3server)://[^\s\[\]]+)(?:\[/url\])?',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
|
||||
const _linkStyle = TextStyle(color: Colors.blue);
|
||||
|
||||
int? _findCloseUrl(String src, int from) {
|
||||
final m = _closeUrlRe.matchAsPrefix(src, from);
|
||||
@@ -42,10 +45,16 @@ Color? _parseColor(String hex) {
|
||||
}
|
||||
|
||||
class BbCodeText extends StatelessWidget {
|
||||
const BbCodeText(this.text, {super.key, required this.linkTrust});
|
||||
const BbCodeText(
|
||||
this.text, {
|
||||
super.key,
|
||||
required this.linkTrust,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final LinkTrustService linkTrust;
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -63,20 +72,17 @@ class BbCodeText extends StatelessWidget {
|
||||
parts.add(TextSpan(text: src.substring(last, m.start)));
|
||||
}
|
||||
final url = m.group(1)!;
|
||||
parts.add(WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url,
|
||||
linkTrust: linkTrust,
|
||||
child: Text(
|
||||
url,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
parts.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url,
|
||||
linkTrust: linkTrust,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
child: Text(url, style: _linkStyle),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
last = m.end;
|
||||
}
|
||||
if (last < src.length) {
|
||||
@@ -118,19 +124,21 @@ class BbCodeText extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
spans.add(TextSpan(
|
||||
text: t,
|
||||
style: TextStyle(
|
||||
fontWeight: bold ? FontWeight.bold : null,
|
||||
fontStyle: italic ? FontStyle.italic : null,
|
||||
decoration: TextDecoration.combine([
|
||||
if (underline) TextDecoration.underline,
|
||||
if (strikethrough) TextDecoration.lineThrough,
|
||||
]),
|
||||
color: color,
|
||||
fontSize: size,
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: t,
|
||||
style: TextStyle(
|
||||
fontWeight: bold ? FontWeight.bold : null,
|
||||
fontStyle: italic ? FontStyle.italic : null,
|
||||
decoration: TextDecoration.combine([
|
||||
if (underline) TextDecoration.underline,
|
||||
if (strikethrough) TextDecoration.lineThrough,
|
||||
]),
|
||||
color: color,
|
||||
fontSize: size,
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
final buf = StringBuffer();
|
||||
@@ -150,7 +158,8 @@ class BbCodeText extends StatelessWidget {
|
||||
}
|
||||
|
||||
flush(buf);
|
||||
final raw = m.group(1)!.toLowerCase();
|
||||
final rawTag = m.group(1)!;
|
||||
final raw = rawTag.toLowerCase();
|
||||
i = m.end;
|
||||
|
||||
if (raw.startsWith('/')) {
|
||||
@@ -184,7 +193,7 @@ class BbCodeText extends StatelessWidget {
|
||||
if (raw.startsWith('color=') || raw.startsWith('size=')) {
|
||||
tags.add(raw);
|
||||
} else if (raw.startsWith('url=')) {
|
||||
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
|
||||
final url = _urlRe.firstMatch(rawTag)?.group(1) ?? '';
|
||||
final closeIdx = _findCloseUrl(src, i);
|
||||
String inner;
|
||||
if (closeIdx != null) {
|
||||
@@ -193,53 +202,49 @@ class BbCodeText extends StatelessWidget {
|
||||
} else {
|
||||
inner = url;
|
||||
}
|
||||
spans.add(WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url.isNotEmpty ? url : inner,
|
||||
linkTrust: linkTrust,
|
||||
child: Text(
|
||||
inner,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url.isNotEmpty ? url : inner,
|
||||
linkTrust: linkTrust,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
child: Text(inner, style: _linkStyle),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
} else if (raw.startsWith('img=')) {
|
||||
final src2 = _imgRe.firstMatch(raw)?.group(1) ?? '';
|
||||
if (src2.isNotEmpty) {
|
||||
spans.add(WidgetSpan(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
Uri.tryParse(src2)?.toString() ?? src2,
|
||||
fit: BoxFit.scaleDown,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
Uri.tryParse(src2)?.toString() ?? src2,
|
||||
fit: BoxFit.scaleDown,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
} else if (raw == 'url') {
|
||||
final closeIdx = _findCloseUrl(src, i);
|
||||
if (closeIdx != null) {
|
||||
final url = src.substring(i, closeIdx).trim();
|
||||
i = closeIdx + 6;
|
||||
spans.add(WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url,
|
||||
linkTrust: linkTrust,
|
||||
child: Text(
|
||||
url,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url,
|
||||
linkTrust: linkTrust,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
child: Text(url, style: _linkStyle),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -258,11 +263,13 @@ class _LinkTap extends StatefulWidget {
|
||||
required this.url,
|
||||
required this.child,
|
||||
required this.linkTrust,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final Widget child;
|
||||
final LinkTrustService linkTrust;
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@override
|
||||
State<_LinkTap> createState() => _LinkTapState();
|
||||
@@ -284,6 +291,12 @@ class _LinkTapState extends State<_LinkTap> {
|
||||
void _onChanged() => mounted ? setState(() {}) : null;
|
||||
|
||||
Future<void> _open() async {
|
||||
final ts3Link = parseTs3ServerLink(widget.url);
|
||||
if (ts3Link != null) {
|
||||
await widget.onTs3ServerLink?.call(ts3Link);
|
||||
return;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri == null) return;
|
||||
final host = uri.host;
|
||||
@@ -305,9 +318,6 @@ class _LinkTapState extends State<_LinkTap> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _open,
|
||||
child: widget.child,
|
||||
);
|
||||
return GestureDetector(onTap: _open, child: widget.child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,13 +280,20 @@ class ChatPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
rust.BridgeMessageTarget? _detailTarget;
|
||||
late rust.BridgeMessageTarget _selectedTarget;
|
||||
String _selectedClientName = '';
|
||||
final Set<BigInt> _closedPrivateChats = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_detailTarget = widget.initialTarget;
|
||||
_selectedTarget =
|
||||
widget.initialTarget ??
|
||||
resolveInitialChatTarget(
|
||||
messages: widget.messages,
|
||||
currentVoiceChannelId: _currentChannelId,
|
||||
) ??
|
||||
const rust.BridgeMessageTarget.server();
|
||||
_selectedClientName = widget.initialClientName;
|
||||
}
|
||||
|
||||
@@ -298,31 +305,102 @@ class _ChatPageState extends State<ChatPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final currentChannelId = _currentChannelId;
|
||||
final channelName = snapshotChannelName(widget.snapshot, currentChannelId);
|
||||
if (_detailTarget != null) {
|
||||
return _ChatDetailView(
|
||||
target: _detailTarget!,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: widget.snapshot,
|
||||
messages: widget.messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onBack: () => setState(() => _detailTarget = null),
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat — ${widget.snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close chat',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _selectedPrivateClientId == null
|
||||
? null
|
||||
: _closeSelectedPrivateChat,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
_ChatSidebar(
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
onNewPrivateChat: () => _pickClient((id, name) {
|
||||
_closedPrivateChats.remove(id);
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: widget.snapshot,
|
||||
messages: widget.messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BigInt? get _selectedPrivateClientId {
|
||||
final target = _selectedTarget;
|
||||
return target is rust.BridgeMessageTarget_Client ? target.field0 : null;
|
||||
}
|
||||
|
||||
List<_PrivateChatItem> get _privateChats {
|
||||
final chats = <BigInt, _PrivateChatItem>{};
|
||||
for (final message in widget.messages) {
|
||||
final target = message.target;
|
||||
if (target is! rust.BridgeMessageTarget_Client) continue;
|
||||
final id = target.field0;
|
||||
if (_closedPrivateChats.contains(id)) continue;
|
||||
final existing = chats[id];
|
||||
final name = existing?.name.isNotEmpty == true
|
||||
? existing!.name
|
||||
: _privateChatName(id, message.senderName);
|
||||
chats[id] = _PrivateChatItem(id: id, name: name);
|
||||
}
|
||||
final selectedId = _selectedPrivateClientId;
|
||||
if (selectedId != null && !_closedPrivateChats.contains(selectedId)) {
|
||||
chats.putIfAbsent(
|
||||
selectedId,
|
||||
() => _PrivateChatItem(
|
||||
id: selectedId,
|
||||
name: _selectedClientName.isNotEmpty ? _selectedClientName : 'Direct',
|
||||
),
|
||||
);
|
||||
}
|
||||
return _ChatHub(
|
||||
snapshot: widget.snapshot,
|
||||
messages: widget.messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onOpen: (t, {String name = ''}) {
|
||||
setState(() {
|
||||
_detailTarget = t;
|
||||
_selectedClientName = name;
|
||||
});
|
||||
},
|
||||
onPickClient: (fn) => _pickClient(fn),
|
||||
);
|
||||
return chats.values.toList()..sort((a, b) => a.name.compareTo(b.name));
|
||||
}
|
||||
|
||||
String _privateChatName(BigInt id, String fallback) {
|
||||
for (final client in widget.snapshot.clients) {
|
||||
if (client.id == id && client.name.isNotEmpty) return client.name;
|
||||
}
|
||||
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
|
||||
}
|
||||
|
||||
void _selectTarget(rust.BridgeMessageTarget target, {String name = ''}) {
|
||||
setState(() {
|
||||
_selectedTarget = target;
|
||||
_selectedClientName = name;
|
||||
});
|
||||
}
|
||||
|
||||
void _closeSelectedPrivateChat() {
|
||||
final id = _selectedPrivateClientId;
|
||||
if (id == null) return;
|
||||
setState(() {
|
||||
_closedPrivateChats.add(id);
|
||||
_selectedTarget = _currentChannelId != null
|
||||
? const rust.BridgeMessageTarget.channel()
|
||||
: const rust.BridgeMessageTarget.server();
|
||||
_selectedClientName = '';
|
||||
});
|
||||
}
|
||||
|
||||
void _pickClient(void Function(BigInt id, String name) cb) {
|
||||
@@ -342,6 +420,134 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivateChatItem {
|
||||
const _PrivateChatItem({required this.id, required this.name});
|
||||
|
||||
final BigInt id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class _ChatSidebar extends StatelessWidget {
|
||||
const _ChatSidebar({
|
||||
required this.selectedTarget,
|
||||
required this.privateChats,
|
||||
required this.onSelect,
|
||||
required this.onNewPrivateChat,
|
||||
});
|
||||
|
||||
final rust.BridgeMessageTarget selectedTarget;
|
||||
final List<_PrivateChatItem> privateChats;
|
||||
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
|
||||
final VoidCallback onNewPrivateChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 148,
|
||||
child: Column(
|
||||
children: [
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
_ChatSidebarItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: privateChats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chat = privateChats[index];
|
||||
final selected = switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) =>
|
||||
field0 == chat.id,
|
||||
_ => false,
|
||||
};
|
||||
return _ChatSidebarItem(
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: selected,
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatSidebarItem extends StatelessWidget {
|
||||
const _ChatSidebarItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bg = selected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: Colors.transparent;
|
||||
final fg = selected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface;
|
||||
return Material(
|
||||
color: bg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: fg),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientPickerDialog extends StatefulWidget {
|
||||
const _ClientPickerDialog({
|
||||
required this.channels,
|
||||
@@ -461,124 +667,6 @@ class _ChannelGroup extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatHub extends StatelessWidget {
|
||||
const _ChatHub({
|
||||
required this.snapshot,
|
||||
required this.messages,
|
||||
required this.currentChannelId,
|
||||
required this.channelName,
|
||||
required this.onOpen,
|
||||
required this.onPickClient,
|
||||
});
|
||||
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
final List<ChatEntry> messages;
|
||||
final BigInt? currentChannelId;
|
||||
final String channelName;
|
||||
final void Function(rust.BridgeMessageTarget t, {String name}) onOpen;
|
||||
final void Function(void Function(BigInt id, String name) cb) onPickClient;
|
||||
|
||||
Iterable<ChatEntry> _of(rust.BridgeMessageTarget t) =>
|
||||
messages.where((m) => m.target == t);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final channelMsgs = _of(const rust.BridgeMessageTarget.channel());
|
||||
final privateMsgs = messages.where((m) => m.isPrivate);
|
||||
final serverMsgs = _of(const rust.BridgeMessageTarget.server());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_HubCard(
|
||||
icon: Icons.tag,
|
||||
title: channelName.isNotEmpty
|
||||
? '# $channelName'
|
||||
: 'Current Channel',
|
||||
subtitle: channelMsgs.isNotEmpty
|
||||
? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}'
|
||||
: 'No messages yet',
|
||||
detail: currentChannelId != null
|
||||
? 'You can send messages here'
|
||||
: 'Join a channel to send messages',
|
||||
onTap: () => onOpen(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HubCard(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Direct Messages',
|
||||
subtitle: privateMsgs.isNotEmpty
|
||||
? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}'
|
||||
: 'No private messages',
|
||||
detail: 'Select a user to start a private chat.',
|
||||
onTap: () => onPickClient(
|
||||
(id, name) =>
|
||||
onOpen(rust.BridgeMessageTarget.client(id), name: name),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_HubCard(
|
||||
icon: Icons.list_alt_outlined,
|
||||
title: 'Server Activity',
|
||||
subtitle: serverMsgs.isNotEmpty
|
||||
? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}'
|
||||
: 'No server activity',
|
||||
detail: serverMsgs.isNotEmpty
|
||||
? 'Server messages and events appear here.'
|
||||
: 'Server-wide messages will appear here.',
|
||||
onTap: () => onOpen(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HubCard extends StatelessWidget {
|
||||
const _HubCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.detail,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String detail;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: theme.colorScheme.primary),
|
||||
title: Text(title, style: theme.textTheme.titleSmall),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: theme.textTheme.bodyMedium),
|
||||
Text(
|
||||
detail,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatDetailView extends StatefulWidget {
|
||||
const _ChatDetailView({
|
||||
required this.target,
|
||||
@@ -587,7 +675,6 @@ class _ChatDetailView extends StatefulWidget {
|
||||
required this.messages,
|
||||
required this.currentChannelId,
|
||||
required this.channelName,
|
||||
required this.onBack,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
@@ -597,7 +684,6 @@ class _ChatDetailView extends StatefulWidget {
|
||||
final List<ChatEntry> messages;
|
||||
final BigInt? currentChannelId;
|
||||
final String channelName;
|
||||
final VoidCallback onBack;
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@override
|
||||
@@ -682,109 +768,108 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
clientName: widget.clientName,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Text(_title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
title: Text(_title),
|
||||
actions: [
|
||||
TextButton(onPressed: widget.onBack, child: const Text('Activity')),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: msgs.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.forum_outlined,
|
||||
size: 48,
|
||||
Expanded(
|
||||
child: msgs.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.forum_outlined,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
chatEmptyTitle(widget.target),
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
chatEmptyBody(
|
||||
widget.target,
|
||||
channelName: widget.channelName,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
chatEmptyTitle(widget.target),
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
chatEmptyBody(
|
||||
widget.target,
|
||||
channelName: widget.channelName,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollCtl,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_blockedReason != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
_blockedReason!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollCtl,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: msgs.length,
|
||||
itemBuilder: (_, i) => _MessageBubble(
|
||||
entry: msgs[i],
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_blockedReason != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
_blockedReason!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_canSend && _blockedReason == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textCtl,
|
||||
decoration: InputDecoration(
|
||||
hintText: placeholder,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
if (_canSend && _blockedReason == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textCtl,
|
||||
decoration: InputDecoration(
|
||||
hintText: placeholder,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _send,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _send,
|
||||
tooltip: 'Send',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Server connection form.
|
||||
class ConnectForm extends StatefulWidget {
|
||||
/// Construct a connect form.
|
||||
const ConnectForm({
|
||||
super.key,
|
||||
required this.hostCtl,
|
||||
required this.nickCtl,
|
||||
required this.passwordCtl,
|
||||
required this.onConnect,
|
||||
required this.onAddBookmark,
|
||||
});
|
||||
|
||||
/// Server host controller.
|
||||
final TextEditingController hostCtl;
|
||||
|
||||
/// Nickname controller.
|
||||
final TextEditingController nickCtl;
|
||||
|
||||
/// Server password controller.
|
||||
final TextEditingController passwordCtl;
|
||||
|
||||
/// Called when the user submits a connection.
|
||||
final VoidCallback onConnect;
|
||||
|
||||
/// Called when the user saves the current form as a bookmark.
|
||||
final VoidCallback onAddBookmark;
|
||||
|
||||
@override
|
||||
State<ConnectForm> createState() => _ConnectFormState();
|
||||
}
|
||||
|
||||
class _ConnectFormState extends State<ConnectForm> {
|
||||
final FocusNode _hostFocus = FocusNode();
|
||||
final FocusNode _nickFocus = FocusNode();
|
||||
final FocusNode _passwordFocus = FocusNode();
|
||||
|
||||
void _onTapOutside(PointerDownEvent _) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostFocus.dispose();
|
||||
_nickFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: widget.hostCtl,
|
||||
focusNode: _hostFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
keyboardType: TextInputType.url,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.deny(RegExp(r'\s')),
|
||||
TextInputFormatter.withFunction(
|
||||
(oldValue, newValue) => newValue.copyWith(
|
||||
text: newValue.text.toLowerCase(),
|
||||
selection: newValue.selection,
|
||||
),
|
||||
),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.fieldServerHost,
|
||||
hintText: 'host[:port]',
|
||||
prefixIcon: const Icon(Icons.dns_outlined),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: widget.nickCtl,
|
||||
focusNode: _nickFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.fieldNickname,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: widget.passwordCtl,
|
||||
focusNode: _passwordFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.fieldServerPassword,
|
||||
helperText: l10n.fieldServerPasswordHelp,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(l10n.connectAction),
|
||||
onPressed: widget.onConnect,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bookmark_add_outlined),
|
||||
label: Text(l10n.bookmarkAddAction),
|
||||
onPressed: widget.onAddBookmark,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved bookmark list.
|
||||
class BookmarkList extends StatelessWidget {
|
||||
/// Construct a bookmark list.
|
||||
const BookmarkList({
|
||||
super.key,
|
||||
required this.bookmarks,
|
||||
required this.onConnect,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
/// Saved bookmarks.
|
||||
final List<rust.BridgeBookmark> bookmarks;
|
||||
|
||||
/// Connect to a bookmark.
|
||||
final ValueChanged<rust.BridgeBookmark> onConnect;
|
||||
|
||||
/// Delete a bookmark.
|
||||
final ValueChanged<rust.BridgeBookmark> onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
if (bookmarks.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
for (final b in bookmarks)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text(b.displayName),
|
||||
subtitle: Text('${b.host} — ${b.nickname}'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.login),
|
||||
tooltip: l10n.connectAction,
|
||||
onPressed: () => onConnect(b),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: l10n.bookmarkDeleteAction,
|
||||
onPressed: () => onDelete(b),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
|
||||
/// stored by the PTT binding flow.
|
||||
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
if (k == LogicalKeyboardKey.space) return 'Space';
|
||||
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
||||
return 'Enter';
|
||||
}
|
||||
if (k == LogicalKeyboardKey.tab) return 'Tab';
|
||||
if (k == LogicalKeyboardKey.escape) return 'Escape';
|
||||
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
|
||||
if (k == LogicalKeyboardKey.delete) return 'Delete';
|
||||
if (k == LogicalKeyboardKey.insert) return 'Insert';
|
||||
if (k == LogicalKeyboardKey.home) return 'Home';
|
||||
if (k == LogicalKeyboardKey.end) return 'End';
|
||||
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
|
||||
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
|
||||
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
|
||||
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
|
||||
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
|
||||
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
|
||||
if (k == LogicalKeyboardKey.shift ||
|
||||
k == LogicalKeyboardKey.shiftLeft ||
|
||||
k == LogicalKeyboardKey.shiftRight ||
|
||||
k == LogicalKeyboardKey.control ||
|
||||
k == LogicalKeyboardKey.controlLeft ||
|
||||
k == LogicalKeyboardKey.controlRight ||
|
||||
k == LogicalKeyboardKey.alt ||
|
||||
k == LogicalKeyboardKey.altLeft ||
|
||||
k == LogicalKeyboardKey.altRight ||
|
||||
k == LogicalKeyboardKey.meta ||
|
||||
k == LogicalKeyboardKey.metaLeft ||
|
||||
k == LogicalKeyboardKey.metaRight ||
|
||||
k == LogicalKeyboardKey.capsLock ||
|
||||
k == LogicalKeyboardKey.numLock ||
|
||||
k == LogicalKeyboardKey.scrollLock) {
|
||||
return null;
|
||||
}
|
||||
final fallback = k.keyLabel.trim();
|
||||
if (fallback.isEmpty) return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// Result of a successful PTT binding capture.
|
||||
class CapturedBinding {
|
||||
const CapturedBinding({required this.inputClass, required this.platformKey});
|
||||
|
||||
/// Coarse input class, safe to persist and display.
|
||||
final rust.BridgePttInputClass inputClass;
|
||||
|
||||
/// Opaque platform-neutral key label.
|
||||
final String platformKey;
|
||||
}
|
||||
|
||||
/// 'Save bookmark' name-entry dialog.
|
||||
class BookmarkNameDialog extends StatefulWidget {
|
||||
/// Construct a bookmark-name dialog.
|
||||
const BookmarkNameDialog({super.key, required this.initialName});
|
||||
|
||||
/// Initial display name.
|
||||
final String initialName;
|
||||
|
||||
@override
|
||||
State<BookmarkNameDialog> createState() => _BookmarkNameDialogState();
|
||||
}
|
||||
|
||||
class _BookmarkNameDialogState extends State<BookmarkNameDialog> {
|
||||
late final TextEditingController _ctl = TextEditingController(
|
||||
text: widget.initialName,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.bookmarkAddTitle),
|
||||
content: TextField(
|
||||
controller: _ctl,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
|
||||
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_ctl.text),
|
||||
child: Text(l10n.bookmarkAddAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel-password dialog.
|
||||
class ChannelPasswordDialog extends StatefulWidget {
|
||||
/// Construct a channel-password dialog.
|
||||
const ChannelPasswordDialog({super.key});
|
||||
|
||||
@override
|
||||
State<ChannelPasswordDialog> createState() => _ChannelPasswordDialogState();
|
||||
}
|
||||
|
||||
class _ChannelPasswordDialogState extends State<ChannelPasswordDialog> {
|
||||
final TextEditingController _ctl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.channelPasswordTitle),
|
||||
content: TextField(
|
||||
controller: _ctl,
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: l10n.fieldPassword),
|
||||
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_ctl.text),
|
||||
child: Text(l10n.connectAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus-scoped dialog that captures the next key press or mouse
|
||||
/// side-button click.
|
||||
class PttBindingCaptureDialog extends StatefulWidget {
|
||||
/// Construct a PTT binding capture dialog.
|
||||
const PttBindingCaptureDialog({super.key});
|
||||
|
||||
@override
|
||||
State<PttBindingCaptureDialog> createState() =>
|
||||
_PttBindingCaptureDialogState();
|
||||
}
|
||||
|
||||
class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
String? _captured;
|
||||
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||
if (label == null) return KeyEventResult.ignored;
|
||||
setState(() {
|
||||
_captured = label;
|
||||
_capturedClass = rust.BridgePttInputClass.keyboard;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _captureMouseSideButton(int button) {
|
||||
setState(() {
|
||||
_captured = 'mouse-side-button:$button';
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.pttConfigureTitle),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _onKeyEvent,
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) {
|
||||
const int back = 0x08;
|
||||
const int forward = 0x10;
|
||||
if (e.buttons == back || e.buttons == forward) {
|
||||
_captureMouseSideButton(e.buttons);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttConfigurePrompt,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
_captured == null
|
||||
? l10n.pttConfigureWaiting
|
||||
: '${l10n.pttConfigureCaptured}: $_captured',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttConfigurePrivacyNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _captured == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
CapturedBinding(
|
||||
inputClass: _capturedClass,
|
||||
platformKey: _captured!,
|
||||
),
|
||||
),
|
||||
child: Text(l10n.pttConfigureSaveAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../services/channel_spacer.dart';
|
||||
import '../services/link_trust_service.dart';
|
||||
import '../services/snapshot_state_mapper.dart';
|
||||
import '../services/ts3_server_link.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
import 'talk_power_warning.dart';
|
||||
|
||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
||||
class SnapshotView extends StatefulWidget {
|
||||
/// Construct a snapshot view.
|
||||
const SnapshotView({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
required this.audioStats,
|
||||
required this.currentVoiceChannelId,
|
||||
required this.pendingVoiceChannelId,
|
||||
required this.localInputMuted,
|
||||
required this.localOutputMuted,
|
||||
required this.hasJoinPending,
|
||||
required this.canJoinVoiceChannel,
|
||||
required this.onJoinChannel,
|
||||
required this.onJoinChannelWithPassword,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
/// Current bridge snapshot.
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
|
||||
/// Latest audio stats, used for local speaking state.
|
||||
final rust.BridgeAudioStats? audioStats;
|
||||
|
||||
/// Current voice channel id.
|
||||
final BigInt? currentVoiceChannelId;
|
||||
|
||||
/// Pending join target, if any.
|
||||
final BigInt? pendingVoiceChannelId;
|
||||
|
||||
/// Local input mute state.
|
||||
final bool localInputMuted;
|
||||
|
||||
/// Local output mute state.
|
||||
final bool localOutputMuted;
|
||||
|
||||
/// True while a channel join is in flight.
|
||||
final bool hasJoinPending;
|
||||
|
||||
/// True when the local client may join voice channels.
|
||||
final bool canJoinVoiceChannel;
|
||||
|
||||
/// Join an unlocked channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||
|
||||
/// Join a password-protected channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@override
|
||||
State<SnapshotView> createState() => _SnapshotViewState();
|
||||
}
|
||||
|
||||
class _SnapshotViewState extends State<SnapshotView> {
|
||||
static const _indentPerLevel = 12.0;
|
||||
static const _expandColumnWidth = 28.0;
|
||||
static const _channelIconColumnWidth = 24.0;
|
||||
static const _channelTextGap = 8.0;
|
||||
static const _userRowStartIndent = 32.0;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
final Map<BigInt, bool> _channelExpandedById = {};
|
||||
bool _welcomeExpanded = true;
|
||||
double _welcomeHeight = 0;
|
||||
final _welcomeKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _welcomeKey.currentContext;
|
||||
if (ctx != null) {
|
||||
final box = ctx.findRenderObject() as RenderBox?;
|
||||
if (box != null && mounted) {
|
||||
setState(() => _welcomeHeight = box.size.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) {
|
||||
setState(() => _welcomeExpanded = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final tree = _buildChannelTree(widget.snapshot.channels);
|
||||
final clientsByChannel = <BigInt, List<rust.BridgeClient>>{};
|
||||
for (final c in widget.snapshot.clients) {
|
||||
if (!c.isServerQuery) {
|
||||
clientsByChannel.putIfAbsent(c.channel, () => []).add(c);
|
||||
}
|
||||
}
|
||||
|
||||
return ListView(
|
||||
controller: _scrollController,
|
||||
children: [
|
||||
Text(
|
||||
l10n.countChannelsAndClients(
|
||||
widget.snapshot.channels.length,
|
||||
widget.snapshot.clients.length,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
if (widget.snapshot.welcomeMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_WelcomeMessageTile(
|
||||
key: _welcomeKey,
|
||||
welcomeMessage: widget.snapshot.welcomeMessage,
|
||||
expanded: _welcomeExpanded,
|
||||
onToggle: () =>
|
||||
setState(() => _welcomeExpanded = !_welcomeExpanded),
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
for (final node in tree.roots)
|
||||
..._channelTreeRows(theme, node, clientsByChannel, 0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _channelTreeRows(
|
||||
ThemeData theme,
|
||||
_ChannelTreeNode node,
|
||||
Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
|
||||
int depth,
|
||||
) {
|
||||
final channel = node.channel;
|
||||
final clients = clientsByChannel[channel.id] ?? const <rust.BridgeClient>[];
|
||||
final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty;
|
||||
final expanded = _isChannelExpanded(channel.id);
|
||||
final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel;
|
||||
|
||||
return [
|
||||
_channelTile(
|
||||
theme,
|
||||
channel,
|
||||
channelIndent: channelIndent,
|
||||
hasVisibleChildren: hasVisibleChildren,
|
||||
expanded: expanded,
|
||||
onToggleExpanded: hasVisibleChildren
|
||||
? () => _toggleChannelExpanded(channel.id)
|
||||
: null,
|
||||
),
|
||||
if (expanded) ...[
|
||||
for (final client in clients) _clientTile(theme, client, channelIndent),
|
||||
for (final child in node.children)
|
||||
..._channelTreeRows(theme, child, clientsByChannel, depth + 1),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _channelTile(
|
||||
ThemeData theme,
|
||||
rust.BridgeChannel channel, {
|
||||
required double channelIndent,
|
||||
required bool hasVisibleChildren,
|
||||
required bool expanded,
|
||||
required VoidCallback? onToggleExpanded,
|
||||
}) {
|
||||
final spacer = parseSpacerChannelName(channel.name);
|
||||
final onTap =
|
||||
widget.hasJoinPending ||
|
||||
!widget.canJoinVoiceChannel ||
|
||||
channel.id == widget.currentVoiceChannelId
|
||||
? null
|
||||
: () => channel.hasPassword
|
||||
? widget.onJoinChannelWithPassword(channel)
|
||||
: widget.onJoinChannel(channel);
|
||||
|
||||
if (spacer.isSpacer) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: channelIndent),
|
||||
_expandButton(
|
||||
theme,
|
||||
hasVisibleChildren: hasVisibleChildren,
|
||||
expanded: expanded,
|
||||
onPressed: onToggleExpanded,
|
||||
),
|
||||
const SizedBox(width: _channelTextGap),
|
||||
Expanded(child: _SpacerChannelContent(spacer: spacer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: channelIndent),
|
||||
_expandButton(
|
||||
theme,
|
||||
hasVisibleChildren: hasVisibleChildren,
|
||||
expanded: expanded,
|
||||
onPressed: onToggleExpanded,
|
||||
),
|
||||
SizedBox(
|
||||
width: _channelIconColumnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(
|
||||
Icons.tag,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _channelTextGap),
|
||||
Expanded(
|
||||
child: Text(
|
||||
channel.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (channel.hasPassword) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _clientTile(
|
||||
ThemeData theme,
|
||||
rust.BridgeClient client,
|
||||
double channelIndent,
|
||||
) {
|
||||
final status = _clientVoiceStatusIcon(theme, client);
|
||||
final nameStyle = client.isServerQuery
|
||||
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
|
||||
: status.isSpeaking
|
||||
? TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
)
|
||||
: null;
|
||||
|
||||
final decoration = status.isSpeaking
|
||||
? BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.55),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: channelIndent + _userRowStartIndent,
|
||||
right: 8,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
decoration: decoration,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: status.icon,
|
||||
title: Text(client.name, style: nameStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandButton(
|
||||
ThemeData theme, {
|
||||
required bool hasVisibleChildren,
|
||||
required bool expanded,
|
||||
required VoidCallback? onPressed,
|
||||
}) {
|
||||
if (!hasVisibleChildren) {
|
||||
return const SizedBox(
|
||||
width: _expandColumnWidth,
|
||||
height: _expandColumnWidth,
|
||||
);
|
||||
}
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: SizedBox(
|
||||
width: _expandColumnWidth,
|
||||
height: _expandColumnWidth,
|
||||
child: Icon(
|
||||
expanded ? Icons.expand_more : Icons.chevron_right,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isChannelExpanded(BigInt channelId) {
|
||||
return _channelExpandedById[channelId] ?? true;
|
||||
}
|
||||
|
||||
void _toggleChannelExpanded(BigInt channelId) {
|
||||
setState(() {
|
||||
_channelExpandedById[channelId] = !_isChannelExpanded(channelId);
|
||||
});
|
||||
}
|
||||
|
||||
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
||||
ThemeData theme,
|
||||
rust.BridgeClient client,
|
||||
) {
|
||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
|
||||
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
|
||||
final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted;
|
||||
final neededTalkPower = snapshotNeededTalkPower(
|
||||
widget.snapshot,
|
||||
client.channel,
|
||||
);
|
||||
final talkPowerBlocked =
|
||||
isSelf &&
|
||||
inCurrentChannel &&
|
||||
isTalkPowerBlocked(
|
||||
talkPower: client.talkPower,
|
||||
neededTalkPower: neededTalkPower,
|
||||
talkPowerGranted: client.talkPowerGranted,
|
||||
);
|
||||
final rawSpeaking = isSelf
|
||||
? (widget.audioStats?.pttActive ?? false)
|
||||
: client.isSpeaking;
|
||||
final transmitAllowed =
|
||||
!outputMuted &&
|
||||
!inputMuted &&
|
||||
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
|
||||
final speaking = rawSpeaking && transmitAllowed;
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String tooltip;
|
||||
if (outputMuted) {
|
||||
icon = Icons.volume_off;
|
||||
color = theme.colorScheme.error;
|
||||
tooltip = 'Speaker muted';
|
||||
} else if (inputMuted) {
|
||||
icon = Icons.mic_off;
|
||||
color = theme.colorScheme.error;
|
||||
tooltip = 'Microphone muted';
|
||||
} else if (talkPowerBlocked) {
|
||||
icon = Icons.volume_off;
|
||||
color = theme.colorScheme.error;
|
||||
tooltip =
|
||||
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
|
||||
} else if (speaking) {
|
||||
icon = isSelf ? Icons.mic : Icons.volume_up;
|
||||
color = theme.colorScheme.primary;
|
||||
tooltip = 'Speaking';
|
||||
} else if (inCurrentChannel) {
|
||||
icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
tooltip = 'Not speaking';
|
||||
} else {
|
||||
icon = Icons.person_outline;
|
||||
color = theme.colorScheme.onSurfaceVariant;
|
||||
tooltip = 'Outside current channel';
|
||||
}
|
||||
|
||||
return (
|
||||
icon: Tooltip(
|
||||
message: tooltip,
|
||||
child: Icon(icon, color: color),
|
||||
),
|
||||
isSpeaking: speaking,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpacerChannelContent extends StatelessWidget {
|
||||
const _SpacerChannelContent({required this.spacer});
|
||||
|
||||
final SpacerChannelNameParseResult spacer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
if (spacer.isBlankSpacer) {
|
||||
return const SizedBox(height: 20);
|
||||
}
|
||||
|
||||
if (spacer.specialType != null) {
|
||||
return SizedBox(
|
||||
height: 22,
|
||||
child: CustomPaint(
|
||||
painter: _SpacerLinePainter(
|
||||
color: color.withValues(alpha: 0.72),
|
||||
type: spacer.specialType!,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (spacer.isRepeating) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final pattern = spacer.text.isEmpty ? ' ' : spacer.text;
|
||||
final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp(
|
||||
1,
|
||||
256,
|
||||
);
|
||||
return Text(
|
||||
channelSpacerLabel(
|
||||
formatSpacerChannelName(
|
||||
SpacerChannelNameFormatOptions(
|
||||
alignment: spacer.alignment,
|
||||
isRepeating: true,
|
||||
uniqueSuffix: spacer.uniqueSuffix,
|
||||
text: pattern,
|
||||
),
|
||||
),
|
||||
repeatColumns: estimatedColumns,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
softWrap: false,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
spacer.text,
|
||||
textAlign: switch (spacer.alignment) {
|
||||
SpacerAlignment.left => TextAlign.left,
|
||||
SpacerAlignment.right => TextAlign.right,
|
||||
SpacerAlignment.center => TextAlign.center,
|
||||
null => TextAlign.center,
|
||||
},
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpacerLinePainter extends CustomPainter {
|
||||
const _SpacerLinePainter({required this.color, required this.type});
|
||||
|
||||
final Color color;
|
||||
final SpacerSpecialType type;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final y = size.height / 2;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeCap = StrokeCap.square
|
||||
..strokeWidth = 1.4;
|
||||
|
||||
switch (type) {
|
||||
case SpacerSpecialType.solidLine:
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||
case SpacerSpecialType.dashLine:
|
||||
_drawPattern(canvas, size.width, y, paint, const [8, 5]);
|
||||
case SpacerSpecialType.dotLine:
|
||||
final dotPaint = Paint()..color = color;
|
||||
for (var x = 1.5; x < size.width; x += 7) {
|
||||
canvas.drawCircle(Offset(x, y), 1.5, dotPaint);
|
||||
}
|
||||
case SpacerSpecialType.dashDotLine:
|
||||
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]);
|
||||
case SpacerSpecialType.dashDotDotLine:
|
||||
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawPattern(
|
||||
Canvas canvas,
|
||||
double width,
|
||||
double y,
|
||||
Paint paint,
|
||||
List<double> pattern,
|
||||
) {
|
||||
var x = 0.0;
|
||||
var index = 0;
|
||||
while (x < width) {
|
||||
final length = pattern[index % pattern.length];
|
||||
if (index.isEven) {
|
||||
final end = x + length > width ? width : x + length;
|
||||
canvas.drawLine(Offset(x, y), Offset(end, y), paint);
|
||||
}
|
||||
x += length;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) {
|
||||
return oldDelegate.color != color || oldDelegate.type != type;
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelTree {
|
||||
const _ChannelTree({required this.roots});
|
||||
|
||||
final List<_ChannelTreeNode> roots;
|
||||
}
|
||||
|
||||
class _ChannelTreeNode {
|
||||
_ChannelTreeNode(this.channel);
|
||||
|
||||
final rust.BridgeChannel channel;
|
||||
final List<_ChannelTreeNode> children = [];
|
||||
}
|
||||
|
||||
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
|
||||
final byParent = <BigInt, List<rust.BridgeChannel>>{};
|
||||
final knownIds = {for (final channel in channels) channel.id};
|
||||
|
||||
for (final channel in channels) {
|
||||
final parent = knownIds.contains(channel.parent)
|
||||
? channel.parent
|
||||
: BigInt.zero;
|
||||
byParent.putIfAbsent(parent, () => []).add(channel);
|
||||
}
|
||||
|
||||
_ChannelTreeNode buildNode(rust.BridgeChannel channel) {
|
||||
final node = _ChannelTreeNode(channel);
|
||||
for (final child in byParent[channel.id] ?? const <rust.BridgeChannel>[]) {
|
||||
node.children.add(buildNode(child));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
return _ChannelTree(
|
||||
roots: [
|
||||
for (final channel
|
||||
in byParent[BigInt.zero] ?? const <rust.BridgeChannel>[])
|
||||
buildNode(channel),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _WelcomeMessageTile extends StatelessWidget {
|
||||
const _WelcomeMessageTile({
|
||||
super.key,
|
||||
required this.welcomeMessage,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
final String welcomeMessage;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onToggle();
|
||||
},
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Server welcome message',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
crossFadeState: expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
bool isTalkPowerBlocked({
|
||||
required int? talkPower,
|
||||
required int? neededTalkPower,
|
||||
required bool? talkPowerGranted,
|
||||
}) {
|
||||
return talkPower != null &&
|
||||
neededTalkPower != null &&
|
||||
talkPower < neededTalkPower &&
|
||||
talkPowerGranted != true;
|
||||
}
|
||||
|
||||
class TalkPowerWarning extends StatelessWidget {
|
||||
const TalkPowerWarning({
|
||||
super.key,
|
||||
required this.talkPower,
|
||||
required this.neededTalkPower,
|
||||
required this.talkPowerGranted,
|
||||
});
|
||||
|
||||
final int? talkPower;
|
||||
final int? neededTalkPower;
|
||||
final bool? talkPowerGranted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isTalkPowerBlocked(
|
||||
talkPower: talkPower,
|
||||
neededTalkPower: neededTalkPower,
|
||||
talkPowerGranted: talkPowerGranted,
|
||||
)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber, size: 18, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Insufficient talk power ($talkPower < $neededTalkPower)',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@
|
||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||
// core's transmit-mode selector + release-tail timer.
|
||||
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:haptic_kit/haptic_kit.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'ptt_capability_badge.dart';
|
||||
import 'voice_compact.dart';
|
||||
import 'voice_level_meter.dart';
|
||||
import 'voice_platform.dart';
|
||||
import 'voice_status_summary.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
||||
@@ -30,27 +30,16 @@ class VoiceBar extends StatelessWidget {
|
||||
required this.pttBackendId,
|
||||
required this.pttBoundInputClass,
|
||||
required this.pttBoundKeyLabel,
|
||||
required this.onToggleMute,
|
||||
required this.onToggleOutputMute,
|
||||
required this.onConfigure,
|
||||
required this.onPttHeldChanged,
|
||||
this.talkPowerBlocked = false,
|
||||
});
|
||||
|
||||
/// True when the session is currently joined to a voice channel.
|
||||
final bool inChannel;
|
||||
|
||||
/// Active transmit mode.
|
||||
final rust.BridgeTransmitMode transmitMode;
|
||||
|
||||
/// Hard-mute clamp state.
|
||||
final bool hardMute;
|
||||
|
||||
/// Speaker (output) mute state. Mirrors the server-broadcast
|
||||
/// `ClientOutputMuted` flag plus the engine's local output
|
||||
/// silencer — toggling this hushes incoming voice immediately
|
||||
/// AND tells the server so other clients see the headphone-off
|
||||
/// icon next to our name.
|
||||
final bool outputMuted;
|
||||
final bool talkPowerBlocked;
|
||||
|
||||
/// Configured release-tail in milliseconds (0..=500). Surfaced as
|
||||
/// a hint underneath the mode badge.
|
||||
@@ -77,12 +66,6 @@ class VoiceBar extends StatelessWidget {
|
||||
/// Platform-neutral key label captured by the binding dialog.
|
||||
final String pttBoundKeyLabel;
|
||||
|
||||
/// Toggle the hard-mute clamp.
|
||||
final VoidCallback onToggleMute;
|
||||
|
||||
/// Toggle speaker (output) mute.
|
||||
final VoidCallback onToggleOutputMute;
|
||||
|
||||
/// Open the voice settings dialog. This is the SINGLE entry point
|
||||
/// for transmit-mode selection, PTT key binding, and release-tail
|
||||
/// configuration. The capability badge below is information-only
|
||||
@@ -98,17 +81,6 @@ class VoiceBar extends StatelessWidget {
|
||||
/// timer handles the trailing tail (SDD-096).
|
||||
final ValueChanged<bool> onPttHeldChanged;
|
||||
|
||||
String _modeLabel(AppL10n l10n) {
|
||||
switch (transmitMode) {
|
||||
case rust.BridgeTransmitMode.ptt:
|
||||
return l10n.voiceModePtt;
|
||||
case rust.BridgeTransmitMode.continuous:
|
||||
return l10n.voiceModeContinuous;
|
||||
case rust.BridgeTransmitMode.voiceActivity:
|
||||
return l10n.voiceModeVoiceActivity;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
@@ -116,344 +88,181 @@ class VoiceBar extends StatelessWidget {
|
||||
final stats = audioStats;
|
||||
final levelActive = stats?.pttActive ?? false;
|
||||
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
||||
final summary = voiceStatusSummary(
|
||||
l10n: l10n,
|
||||
transmitMode: transmitMode,
|
||||
releaseTailMs: releaseTailMs,
|
||||
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: hardMute,
|
||||
outputMuted: outputMuted,
|
||||
pttActive: stats?.pttActive ?? false,
|
||||
talkPower: talkPowerBlocked ? 0 : null,
|
||||
neededTalkPower: talkPowerBlocked ? 1 : null,
|
||||
talkPowerGranted: false,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Row 1: channel pill + mute toggle. The pill is
|
||||
// wrapped in `Flexible` so very long channel names
|
||||
// truncate with an ellipsis instead of overflowing the
|
||||
// Voice Bar's column width (320 dp in the wide layout)
|
||||
// and pushing the mute icons under the adjacent channel
|
||||
// tree.
|
||||
Row(
|
||||
children: [
|
||||
if (inChannel && channelName.isNotEmpty) ...[
|
||||
Flexible(
|
||||
flex: 100,
|
||||
fit: FlexFit.loose,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tag,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
channelName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.withValues(alpha: 0.18)
|
||||
: summary.muted
|
||||
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.shade700
|
||||
: summary.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Row 1: mode badge
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? Icons.radio_button_checked
|
||||
: Icons.podcasts,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
summary.modeLabel,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 16),
|
||||
label: Text(l10n.voiceSettingsTitle),
|
||||
onPressed: onConfigure,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceOutputMuteLabel,
|
||||
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
onPressed: onToggleOutputMute,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceHardMuteLabel,
|
||||
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
onPressed: onToggleMute,
|
||||
),
|
||||
// Status row: talk power / mic / speaker state.
|
||||
if (inChannel) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
summary.talkPowerBlocked
|
||||
? 'Insufficient talk power'
|
||||
: summary.statusText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.shade700
|
||||
: summary.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Row 2: mode badge
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? Icons.radio_button_checked
|
||||
: Icons.podcasts,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
// Row 3: PTT-only secondary content.
|
||||
//
|
||||
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
||||
// Web) this is a one-line bound-key + release-tail hint
|
||||
// sitting right under the mode badge.
|
||||
//
|
||||
// On touch-only hosts (iOS / iPadOS / Android) the
|
||||
// on-screen Push to Talk button is rendered AT THE
|
||||
// BOTTOM of the Voice Bar (see below) so it sits
|
||||
// closest to the user's thumb when the Voice Bar is
|
||||
// pinned to the bottom of a narrow-layout screen. The
|
||||
// release-tail value is folded into the small print
|
||||
// under the button rather than shown here.
|
||||
if (isPtt && !isTouchOnlyPttHost)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||
child: Text(
|
||||
_modeLabel(l10n),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
'${l10n.voiceModePtt}: '
|
||||
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
||||
' · '
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 16),
|
||||
label: Text(l10n.voiceSettingsTitle),
|
||||
onPressed: onConfigure,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Row 3: PTT-only secondary content.
|
||||
//
|
||||
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
||||
// Web) this is a one-line bound-key + release-tail hint
|
||||
// sitting right under the mode badge.
|
||||
//
|
||||
// On touch-only hosts (iOS / iPadOS / Android) the
|
||||
// on-screen Push to Talk button is rendered AT THE
|
||||
// BOTTOM of the Voice Bar (see below) so it sits
|
||||
// closest to the user's thumb when the Voice Bar is
|
||||
// pinned to the bottom of a narrow-layout screen. The
|
||||
// release-tail value is folded into the small print
|
||||
// under the button rather than shown here.
|
||||
if (isPtt && !isTouchOnlyPttHost)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||
child: Text(
|
||||
'${l10n.voiceModePtt}: '
|
||||
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
||||
' · '
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Row 4: level meter
|
||||
_LevelMeter(active: levelActive),
|
||||
const SizedBox(height: 4),
|
||||
if (stats != null)
|
||||
Text(
|
||||
l10n.audioStatsLine(
|
||||
stats.framesSent,
|
||||
stats.framesReceived,
|
||||
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// PTT capability badge — only relevant when PTT mode is
|
||||
// active. Hidden for Continuous / Voice Activity since
|
||||
// there's no key binding to surface a capability for.
|
||||
// The badge is information-only; the user reaches the
|
||||
// bind-key flow through the Voice Bar's settings gear
|
||||
// (single configuration entry point — see the comment
|
||||
// on `onConfigure`).
|
||||
//
|
||||
// Still shown on touch-only mobile hosts because iOS P0
|
||||
// acceptance requires an explicit `L0Focused` badge and
|
||||
// explanation that global hotkeys are not available in
|
||||
// the iOS sandbox.
|
||||
if (isPtt)
|
||||
PttCapabilityBadge(
|
||||
level: pttLevel,
|
||||
backendId: pttBackendId,
|
||||
boundInputClass: pttBoundInputClass,
|
||||
),
|
||||
// On touch-only mobile hosts the Push to Talk button is
|
||||
// the LAST element of the Voice Bar so it lands closest
|
||||
// to the user's thumb when the Voice Bar is pinned to
|
||||
// the bottom of a narrow-layout screen. The release-
|
||||
// tail value sits above the button so the user sees
|
||||
// how long their voice continues after they let go.
|
||||
if (isPtt && isTouchOnlyPttHost) ...[
|
||||
const SizedBox(height: 6),
|
||||
// Row 4: level meter
|
||||
VoiceLevelMeter(active: levelActive),
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
if (stats != null)
|
||||
Text(
|
||||
l10n.audioStatsLine(
|
||||
stats.framesSent,
|
||||
stats.framesReceived,
|
||||
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PttHoldButton(
|
||||
active: levelActive,
|
||||
onHeldChanged: onPttHeldChanged,
|
||||
),
|
||||
],
|
||||
// Leave-voice button intentionally absent: TeamSpeak's
|
||||
// model is "user is always in some channel", not
|
||||
// Discord's join/leave-voice. To stop being heard /
|
||||
// hearing others, mute mic and/or speaker via the
|
||||
// icons at the top of the bar. To physically move,
|
||||
// tap a different channel in the tree below.
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LevelMeter extends StatelessWidget {
|
||||
const _LevelMeter({required this.active});
|
||||
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
widthFactor: active ? 0.75 : 0.05,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On-screen push-to-talk button for touch-only mobile platforms
|
||||
/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in
|
||||
/// favour of a bound key.
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * `onPanDown` (finger touches the button) → fires
|
||||
/// `onHeldChanged(true)`. The Rust release-tail timer treats
|
||||
/// this as `key_down`.
|
||||
/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) →
|
||||
/// fires `onHeldChanged(false)` → `key_up` → tail expires →
|
||||
/// mic closes.
|
||||
///
|
||||
/// Using `GestureDetector` rather than `Listener` because we want
|
||||
/// gesture-arena semantics: if the user starts dragging the
|
||||
/// channel-tree underneath, the PTT should release. `onPanCancel`
|
||||
/// fires in that case.
|
||||
///
|
||||
/// The button visually mirrors the `_LevelMeter` state via the
|
||||
/// `active` flag so the user gets feedback that holding actually
|
||||
/// engaged the mic.
|
||||
class _PttHoldButton extends StatefulWidget {
|
||||
const _PttHoldButton({required this.active, required this.onHeldChanged});
|
||||
|
||||
final bool active;
|
||||
final ValueChanged<bool> onHeldChanged;
|
||||
|
||||
@override
|
||||
State<_PttHoldButton> createState() => _PttHoldButtonState();
|
||||
}
|
||||
|
||||
class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Haptics.prepare().catchError((_) => false));
|
||||
}
|
||||
|
||||
void _setHeld(bool held) {
|
||||
if (_pressed == held) return;
|
||||
setState(() => _pressed = held);
|
||||
widget.onHeldChanged(held);
|
||||
_playPressHaptic(held);
|
||||
}
|
||||
|
||||
void _playPressHaptic(bool held) {
|
||||
final haptic = held
|
||||
? Haptics.impact(HapticImpactStyle.medium)
|
||||
: Haptics.selection();
|
||||
unawaited(haptic.catchError((_) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final activeNow = _pressed || widget.active;
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
liveRegion: true,
|
||||
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setHeld(true),
|
||||
onTapUp: (_) => _setHeld(false),
|
||||
onTapCancel: () => _setHeld(false),
|
||||
onPanDown: (_) => _setHeld(true),
|
||||
onPanEnd: (_) => _setHeld(false),
|
||||
onPanCancel: () => _setHeld(false),
|
||||
child: ExcludeSemantics(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: activeNow
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: activeNow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
activeNow ? Icons.mic : Icons.mic_none,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
const SizedBox(height: 6),
|
||||
// PTT capability badge — only relevant when PTT mode is
|
||||
// active. Hidden for Continuous / Voice Activity since
|
||||
// there's no key binding to surface a capability for.
|
||||
// The badge is information-only; the user reaches the
|
||||
// bind-key flow through the Voice Bar's settings gear
|
||||
// (single configuration entry point — see the comment
|
||||
// on `onConfigure`).
|
||||
//
|
||||
// Still shown on touch-only mobile hosts because iOS P0
|
||||
// acceptance requires an explicit `L0Focused` badge and
|
||||
// explanation that global hotkeys are not available in
|
||||
// the iOS sandbox.
|
||||
if (isPtt)
|
||||
PttCapabilityBadge(
|
||||
level: pttLevel,
|
||||
backendId: pttBackendId,
|
||||
boundInputClass: pttBoundInputClass,
|
||||
),
|
||||
// On touch-only mobile hosts the Push to Talk button is
|
||||
// the LAST element of the Voice Bar so it lands closest
|
||||
// to the user's thumb when the Voice Bar is pinned to
|
||||
// the bottom of a narrow-layout screen. The release-
|
||||
// tail value sits above the button so the user sees
|
||||
// how long their voice continues after they let go.
|
||||
if (isPtt && isTouchOnlyPttHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: levelActive,
|
||||
onHeldChanged: onPttHeldChanged,
|
||||
height: 64,
|
||||
borderRadius: 12,
|
||||
iconSize: 24,
|
||||
iconGap: 10,
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
listenForPan: true,
|
||||
labelLetterSpacing: null,
|
||||
),
|
||||
],
|
||||
// Leave-voice button intentionally absent: TeamSpeak's
|
||||
// model is "user is always in some channel", not
|
||||
// Discord's join/leave-voice. To stop being heard /
|
||||
// hearing others, mute mic and/or speaker via the
|
||||
// icons at the top of the bar. To physically move,
|
||||
// tap a different channel in the tree below.
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:haptic_kit/haptic_kit.dart';
|
||||
|
||||
/// Prepare mobile haptics without surfacing platform failures.
|
||||
void prepareVoiceHaptics() {
|
||||
unawaited(Haptics.prepare().catchError((_) => false));
|
||||
}
|
||||
|
||||
/// Play the standard touch PTT haptic without surfacing platform failures.
|
||||
void playVoicePttHaptic(bool held) {
|
||||
final haptic = held
|
||||
? Haptics.impact(HapticImpactStyle.medium)
|
||||
: Haptics.selection();
|
||||
unawaited(haptic.catchError((_) {}));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared compact level meter used by voice surfaces.
|
||||
class VoiceLevelMeter extends StatelessWidget {
|
||||
const VoiceLevelMeter({super.key, required this.active});
|
||||
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
widthFactor: active ? 0.75 : 0.05,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,19 @@
|
||||
// - VAD backend
|
||||
// - iOS voice processing mode
|
||||
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'audio_device_list_tile.dart';
|
||||
import 'audio_output_tile.dart';
|
||||
import 'audio_processing_config_state.dart';
|
||||
import 'ptt_capability_badge.dart';
|
||||
import 'talk_power_warning.dart';
|
||||
import 'voice_platform.dart';
|
||||
import 'voice_settings_controls.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
bool get _isAndroid {
|
||||
@@ -50,11 +54,23 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
required this.initialMode,
|
||||
required this.initialReleaseTailMs,
|
||||
required this.initialAudioConfig,
|
||||
this.pttLevel = '',
|
||||
this.pttBackendId = '',
|
||||
this.pttBoundInputClass = '',
|
||||
this.talkPower,
|
||||
this.neededTalkPower,
|
||||
this.talkPowerGranted,
|
||||
});
|
||||
|
||||
final rust.BridgeTransmitMode initialMode;
|
||||
final int initialReleaseTailMs;
|
||||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||
final String pttLevel;
|
||||
final String pttBackendId;
|
||||
final String pttBoundInputClass;
|
||||
final int? talkPower;
|
||||
final int? neededTalkPower;
|
||||
final bool? talkPowerGranted;
|
||||
|
||||
@override
|
||||
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
|
||||
@@ -64,16 +80,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
late rust.BridgeTransmitMode _mode;
|
||||
late double _releaseTail;
|
||||
|
||||
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
|
||||
late bool _nsEnabled;
|
||||
late bool _aecEnabled;
|
||||
late bool _agcEnabled;
|
||||
late bool _hpfEnabled;
|
||||
late bool _limiterEnabled;
|
||||
late rust.BridgeVadBackend _vadBackend;
|
||||
late rust.BridgeIosVoiceProcessingMode _iosMode;
|
||||
late bool _debugWavDump;
|
||||
late bool _preferHardware; // Android only: try JNI hardware effects
|
||||
late final AudioProcessingConfigState _audioProcessing;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -81,93 +88,15 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
_mode = widget.initialMode;
|
||||
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
|
||||
|
||||
final c = widget.initialAudioConfig;
|
||||
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
|
||||
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
|
||||
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
|
||||
_hpfEnabled = c.hpfEnabled;
|
||||
_limiterEnabled = c.limiterEnabled;
|
||||
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: c.vadBackend;
|
||||
_iosMode = c.iosMode;
|
||||
_debugWavDump = c.debugWavDumpEnabled;
|
||||
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|
||||
|| c.ns == rust.BridgeEffectOwner.platform
|
||||
|| c.agc == rust.BridgeEffectOwner.platform;
|
||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
final c = widget.initialAudioConfig;
|
||||
final isSonora =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
|
||||
if (_isAndroid) {
|
||||
final owner = _preferHardware
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: _preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend,
|
||||
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: _limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: _debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
// iOS / macOS: VPIO vs Sonora paths.
|
||||
// In VPIO mode, enabled effects are platform-owned. The experimental raw
|
||||
// path uses WebRTC APM ownership so config validation stays honest.
|
||||
final aecOwner = isSonora
|
||||
? (_aecEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||||
final nsOwner = isSonora
|
||||
? (_nsEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (_agcEnabled
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: isSonora
|
||||
? rust.BridgeAudioBackend.webrtcApm
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vadBackend,
|
||||
aec: aecOwner,
|
||||
ns: nsOwner,
|
||||
agc: agcOwner,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: _limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: _debugWavDump,
|
||||
return _audioProcessing.buildConfig(
|
||||
base: widget.initialAudioConfig,
|
||||
isAndroid: _isAndroid,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,7 +105,8 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final platformVpio =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
_audioProcessing.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
|
||||
return AlertDialog(
|
||||
title: Text(l10n.voiceSettingsTitle),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
@@ -188,24 +118,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Transmit mode ──────────────────────────────────────
|
||||
_sectionHeader(theme, l10n.voiceModeLabel),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModePtt),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeContinuous),
|
||||
onSelected: (v) => _mode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeTransmitMode>(
|
||||
value: rust.BridgeTransmitMode.voiceActivity,
|
||||
groupValue: _mode,
|
||||
title: Text(l10n.voiceModeVoiceActivity),
|
||||
onSelected: (v) => _mode = v,
|
||||
VoiceSectionHeader(l10n.voiceModeLabel),
|
||||
SegmentedButton<rust.BridgeTransmitMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: transmitModeSegments,
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (s) => setState(() => _mode = s.first),
|
||||
),
|
||||
|
||||
// ── PTT options ────────────────────────────────────────
|
||||
@@ -257,133 +175,148 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
|
||||
// ── Audio processing ───────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Audio processing'),
|
||||
const VoiceSectionHeader('Audio processing'),
|
||||
|
||||
// iOS mode selector (iOS only)
|
||||
if (_isIos) ...[
|
||||
_subHeader(theme, 'Processing backend'),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value:
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Platform (VPIO)'),
|
||||
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
),
|
||||
_radioTile<rust.BridgeIosVoiceProcessingMode>(
|
||||
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
groupValue: _iosMode,
|
||||
title: const Text('Sonora (experimental)'),
|
||||
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
|
||||
onSelected: (v) => _iosMode = v,
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: iosProcessingSegments,
|
||||
selected: {_audioProcessing.iosMode},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.iosMode = s.first),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// Android HW/SW selector
|
||||
if (_isAndroid) ...[
|
||||
_subHeader(theme, 'Processing backend'),
|
||||
_radioTile<bool>(
|
||||
value: true,
|
||||
groupValue: _preferHardware,
|
||||
title: const Text('Platform (auto)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Try hardware JNI effects · software fallback',
|
||||
),
|
||||
onSelected: (v) => setState(() => _preferHardware = v),
|
||||
),
|
||||
_radioTile<bool>(
|
||||
value: false,
|
||||
groupValue: _preferHardware,
|
||||
title: const Text('WebRTC APM'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Software AEC3 · NS · AGC2',
|
||||
),
|
||||
onSelected: (v) => setState(() => _preferHardware = v),
|
||||
const VoiceSubHeader('Processing backend'),
|
||||
SegmentedButton<bool>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: androidProcessingSegments,
|
||||
selected: {_audioProcessing.preferHardware},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.preferHardware = s.first),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
_subHeader(theme, 'DSP stages'),
|
||||
_switchTile(
|
||||
title: 'Noise suppression (NS)',
|
||||
const VoiceSubHeader('DSP stages'),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Noise suppression (NS)',
|
||||
subtitle: 'Wiener filter · stationary noise',
|
||||
value: _nsEnabled,
|
||||
onSelected: (v) => _nsEnabled = v,
|
||||
value: _audioProcessing.nsEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.nsEnabled = v),
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Echo cancellation (AEC3)',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Echo cancellation (AEC3)',
|
||||
subtitle: _isAndroid
|
||||
? 'WebRTC AEC3 · adaptive filter'
|
||||
: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _aecEnabled,
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _audioProcessing.aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
|
||||
onChanged: (_isAndroid || !platformVpio)
|
||||
? (v) => setState(() => _audioProcessing.aecEnabled = v)
|
||||
: null,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Auto gain control (AGC2)',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Auto gain control (AGC2)',
|
||||
subtitle: 'RNN VAD-gated · −18 dBFS target',
|
||||
value: _agcEnabled,
|
||||
onSelected: (v) => _agcEnabled = v,
|
||||
value: _audioProcessing.agcEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.agcEnabled = v),
|
||||
),
|
||||
_switchTile(
|
||||
title: 'High-pass filter (HPF)',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'High-pass filter (HPF)',
|
||||
subtitle: '80 Hz Butterworth · DC removal',
|
||||
value: _hpfEnabled,
|
||||
onSelected: (v) => _hpfEnabled = v,
|
||||
value: _audioProcessing.hpfEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.hpfEnabled = v),
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Peak limiter',
|
||||
AudioProcessingToggleRow(
|
||||
label: 'Peak limiter',
|
||||
subtitle: '−1 dBFS soft-knee · 2 ms look-ahead',
|
||||
value: _limiterEnabled,
|
||||
onSelected: (v) => _limiterEnabled = v,
|
||||
value: _audioProcessing.limiterEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.limiterEnabled = v),
|
||||
),
|
||||
|
||||
if (isTalkPowerBlocked(
|
||||
talkPower: widget.talkPower,
|
||||
neededTalkPower: widget.neededTalkPower,
|
||||
talkPowerGranted: widget.talkPowerGranted,
|
||||
)) ...[
|
||||
const SizedBox(height: 8),
|
||||
TalkPowerWarning(
|
||||
talkPower: widget.talkPower,
|
||||
neededTalkPower: widget.neededTalkPower,
|
||||
talkPowerGranted: widget.talkPowerGranted,
|
||||
),
|
||||
],
|
||||
|
||||
// ── VAD ────────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Voice activity detection (VAD)'),
|
||||
const VoiceSectionHeader('Voice activity detection (VAD)'),
|
||||
|
||||
_subHeader(theme, 'Backend'),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.webrtcVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('WebRTC VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Fast · energy-based · always available',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('Silero v6 (ONNX)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 32 ms frames · requires model file',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
),
|
||||
_radioTile<rust.BridgeVadBackend>(
|
||||
value: rust.BridgeVadBackend.tenVad,
|
||||
groupValue: _vadBackend,
|
||||
title: const Text('TEN VAD'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Neural · 16 kHz · native runtime optional',
|
||||
),
|
||||
onSelected: (v) => _vadBackend = v,
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
widget.pttLevel.isNotEmpty) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('PTT capability'),
|
||||
PttCapabilityBadge(
|
||||
level: widget.pttLevel,
|
||||
backendId: widget.pttBackendId,
|
||||
boundInputClass: widget.pttBoundInputClass,
|
||||
),
|
||||
],
|
||||
|
||||
// ── Audio output route picker (mobile only) ─────────────
|
||||
if (_isAndroid || _isIos) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio output'),
|
||||
const AudioOutputTile(),
|
||||
],
|
||||
|
||||
// ── Audio devices (desktop only, SRS-026) ──────────────
|
||||
if (!_isAndroid && !_isIos) ...[
|
||||
const Divider(height: 24),
|
||||
const VoiceSectionHeader('Audio devices'),
|
||||
const AudioDeviceListTile(
|
||||
label: 'Input',
|
||||
kind: AudioDeviceKind.input,
|
||||
),
|
||||
const AudioDeviceListTile(
|
||||
label: 'Output',
|
||||
kind: AudioDeviceKind.output,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────
|
||||
const Divider(height: 24),
|
||||
_sectionHeader(theme, 'Debug'),
|
||||
_switchTile(
|
||||
title: 'WAV dump',
|
||||
const VoiceSectionHeader('Debug'),
|
||||
AudioProcessingToggleRow(
|
||||
label: 'WAV dump',
|
||||
subtitle: 'Record raw/processed mic to temp dir',
|
||||
value: _debugWavDump,
|
||||
onSelected: (v) => _debugWavDump = v,
|
||||
value: _audioProcessing.debugWavDump,
|
||||
onChanged: (v) =>
|
||||
setState(() => _audioProcessing.debugWavDump = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -409,54 +342,4 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _radioTile<T>({
|
||||
required T value,
|
||||
required T groupValue,
|
||||
required Widget title,
|
||||
Widget? subtitle,
|
||||
required ValueChanged<T> onSelected,
|
||||
}) => RadioListTile<T>(
|
||||
dense: true,
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => onSelected(v));
|
||||
},
|
||||
);
|
||||
|
||||
Widget _switchTile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onSelected,
|
||||
}) => SwitchListTile(
|
||||
dense: true,
|
||||
title: Text(title),
|
||||
subtitle: _tileSubtitle(subtitle),
|
||||
value: value,
|
||||
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
|
||||
);
|
||||
|
||||
Widget _tileSubtitle(String text) =>
|
||||
Text(text, style: const TextStyle(fontSize: 11));
|
||||
|
||||
Widget _sectionHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(text, style: theme.textTheme.titleSmall),
|
||||
);
|
||||
|
||||
Widget _subHeader(ThemeData theme, String text) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Shared compact style for voice settings segmented buttons.
|
||||
ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
|
||||
return SegmentedButton.styleFrom(
|
||||
textStyle: theme.textTheme.labelSmall,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
|
||||
/// Transmit mode selector segments.
|
||||
const transmitModeSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.ptt,
|
||||
label: Text('PTT'),
|
||||
icon: Icon(Icons.radio_button_checked, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.continuous,
|
||||
label: Text('Always'),
|
||||
icon: Icon(Icons.podcasts, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeTransmitMode.voiceActivity,
|
||||
label: Text('VAD'),
|
||||
icon: Icon(Icons.graphic_eq, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Android hardware/WebRTC selector segments.
|
||||
const androidProcessingSegments = [
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
label: Text('Hardware'),
|
||||
icon: Icon(Icons.phone_android, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text('WebRTC'),
|
||||
icon: Icon(Icons.science_outlined, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// iOS VPIO/Sonora selector segments.
|
||||
const iosProcessingSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
label: Text('VPIO'),
|
||||
icon: Icon(Icons.phone_iphone, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
|
||||
label: Text('Sonora'),
|
||||
icon: Icon(Icons.science_outlined, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Voice activity detector selector segments.
|
||||
const vadBackendSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.webrtcVad,
|
||||
label: Text('WebRTC'),
|
||||
icon: Icon(Icons.speed, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
label: Text('Silero'),
|
||||
icon: Icon(Icons.psychology, size: 14),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.tenVad,
|
||||
label: Text('TEN'),
|
||||
icon: Icon(Icons.graphic_eq, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Section subheader used by both voice settings surfaces.
|
||||
class VoiceSubHeader extends StatelessWidget {
|
||||
/// Construct a voice settings subheader.
|
||||
const VoiceSubHeader(this.text, {super.key});
|
||||
|
||||
/// Header text.
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Section header used by the full voice settings dialog.
|
||||
class VoiceSectionHeader extends StatelessWidget {
|
||||
/// Construct a voice settings section header.
|
||||
const VoiceSectionHeader(this.text, {super.key});
|
||||
|
||||
/// Header text.
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(text, style: theme.textTheme.titleSmall),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared audio-processing switch row.
|
||||
class AudioProcessingToggleRow extends StatelessWidget {
|
||||
/// Construct an audio-processing switch row.
|
||||
const AudioProcessingToggleRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
/// Primary row label.
|
||||
final String label;
|
||||
|
||||
/// Secondary row detail.
|
||||
final String subtitle;
|
||||
|
||||
/// Current switch value.
|
||||
final bool value;
|
||||
|
||||
/// Called when the switch changes. Null disables the row.
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
/// Use the compact inline row layout used by the mobile sheet.
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (dense) return _buildDense(context);
|
||||
return SwitchListTile(
|
||||
dense: true,
|
||||
title: Text(label),
|
||||
subtitle: Text(subtitle, style: const TextStyle(fontSize: 11)),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDense(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final disabled = onChanged == null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: disabled
|
||||
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant.withAlpha(
|
||||
disabled ? 80 : 160,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'talk_power_warning.dart';
|
||||
|
||||
class VoiceStatusSummary {
|
||||
const VoiceStatusSummary({
|
||||
required this.modeLabel,
|
||||
required this.line1,
|
||||
required this.line2,
|
||||
required this.statusText,
|
||||
required this.micOn,
|
||||
required this.talkPowerBlocked,
|
||||
required this.muted,
|
||||
});
|
||||
|
||||
final String modeLabel;
|
||||
final String line1;
|
||||
final String line2;
|
||||
final String statusText;
|
||||
final bool micOn;
|
||||
final bool talkPowerBlocked;
|
||||
final bool muted;
|
||||
}
|
||||
|
||||
String voiceModeLabel(AppL10n l10n, rust.BridgeTransmitMode transmitMode) {
|
||||
switch (transmitMode) {
|
||||
case rust.BridgeTransmitMode.ptt:
|
||||
return l10n.voiceModePtt;
|
||||
case rust.BridgeTransmitMode.continuous:
|
||||
return l10n.voiceModeContinuous;
|
||||
case rust.BridgeTransmitMode.voiceActivity:
|
||||
return l10n.voiceModeVoiceActivity;
|
||||
}
|
||||
}
|
||||
|
||||
VoiceStatusSummary voiceStatusSummary({
|
||||
required AppL10n l10n,
|
||||
required rust.BridgeTransmitMode transmitMode,
|
||||
required int releaseTailMs,
|
||||
required String pttBoundKeyLabel,
|
||||
required bool isTouchOnly,
|
||||
required bool inputMuted,
|
||||
required bool outputMuted,
|
||||
required bool pttActive,
|
||||
int? talkPower,
|
||||
int? neededTalkPower,
|
||||
bool? talkPowerGranted,
|
||||
}) {
|
||||
final modeLabel = voiceModeLabel(l10n, transmitMode);
|
||||
final talkPowerBlocked = isTalkPowerBlocked(
|
||||
talkPower: talkPower,
|
||||
neededTalkPower: neededTalkPower,
|
||||
talkPowerGranted: talkPowerGranted,
|
||||
);
|
||||
final micOn = inputMuted || outputMuted || talkPowerBlocked
|
||||
? false
|
||||
: switch (transmitMode) {
|
||||
rust.BridgeTransmitMode.continuous => true,
|
||||
_ => pttActive,
|
||||
};
|
||||
|
||||
final line1 = transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? isTouchOnly
|
||||
? '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'
|
||||
: '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'
|
||||
: modeLabel;
|
||||
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
|
||||
: null;
|
||||
final statusText = talkPowerBlocked
|
||||
? 'Insufficient permission'
|
||||
: inputMuted
|
||||
? '${l10n.voiceMicOff} (muted)'
|
||||
: outputMuted
|
||||
? 'Speaker muted'
|
||||
: micOn
|
||||
? l10n.voiceMicOn
|
||||
: l10n.voiceMicOff;
|
||||
|
||||
return VoiceStatusSummary(
|
||||
modeLabel: modeLabel,
|
||||
line1: line1,
|
||||
line2: tailText == null ? statusText : '$tailText \u00b7 $statusText',
|
||||
statusText: statusText,
|
||||
micOn: micOn,
|
||||
talkPowerBlocked: talkPowerBlocked,
|
||||
muted: inputMuted || outputMuted,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user