951 lines
32 KiB
Dart
951 lines
32 KiB
Dart
// Compact voice UI for narrow / mobile layouts (Plan E hybrid:
|
||
// AppBar mutes + status chip with 2-line live readout + wide bottom-
|
||
// anchored PTT button + modal sheet for non-essential controls).
|
||
//
|
||
// rc.8 follow-up: the AppBar gear icon was removed; the modal sheet
|
||
// is now the **single** voice-controls surface on mobile. Mode +
|
||
// release-tail are surfaced inline (radio buttons + slider) inside
|
||
// the modal.
|
||
|
||
import 'dart:async' show Timer, unawaited;
|
||
import 'dart:io' show Platform;
|
||
|
||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:haptic_kit/haptic_kit.dart' show Haptics;
|
||
|
||
import '../l10n/generated/app_localizations.dart';
|
||
import 'audio_output_tile.dart';
|
||
import 'audio_processing_config_state.dart';
|
||
import 'ptt_capability_badge.dart';
|
||
import 'talk_power_warning.dart';
|
||
import 'voice_haptics.dart';
|
||
import 'voice_level_meter.dart';
|
||
import 'voice_settings_controls.dart';
|
||
import 'voice_status_summary.dart';
|
||
import '../src/rust/api.dart' as rust;
|
||
|
||
bool get _isIos {
|
||
if (kIsWeb) return false;
|
||
return Platform.isIOS;
|
||
}
|
||
|
||
bool get _isMacOS {
|
||
if (kIsWeb) return false;
|
||
return Platform.isMacOS;
|
||
}
|
||
|
||
bool get _isDesktopSileroVadHost {
|
||
if (kIsWeb) return false;
|
||
return Platform.isWindows || Platform.isLinux;
|
||
}
|
||
|
||
/// Two-line status chip that summarises the current voice state.
|
||
/// Tap to open the voice details modal.
|
||
class VoiceStatusChip extends StatelessWidget {
|
||
/// Construct a status chip.
|
||
const VoiceStatusChip({
|
||
super.key,
|
||
required this.transmitMode,
|
||
required this.releaseTailMs,
|
||
required this.pttBoundKeyLabel,
|
||
required this.audioStats,
|
||
required this.isTouchOnly,
|
||
required this.onTap,
|
||
this.inputMuted = false,
|
||
this.outputMuted = false,
|
||
this.talkPower,
|
||
this.neededTalkPower,
|
||
this.talkPowerGranted,
|
||
});
|
||
|
||
/// Current transmit mode.
|
||
final rust.BridgeTransmitMode transmitMode;
|
||
|
||
/// Release-tail in milliseconds.
|
||
final int releaseTailMs;
|
||
|
||
/// Bound key label (empty on touch-only hosts).
|
||
final String pttBoundKeyLabel;
|
||
|
||
/// Current audio stats; null while audio engine not running.
|
||
final rust.BridgeAudioStats? audioStats;
|
||
|
||
/// True on iOS / iPadOS / Android.
|
||
final bool isTouchOnly;
|
||
|
||
/// True when local mic is muted (hard mute or permission mute).
|
||
final bool inputMuted;
|
||
|
||
/// True when local speaker is muted.
|
||
final bool outputMuted;
|
||
|
||
/// Own client's talk power.
|
||
final int? talkPower;
|
||
|
||
/// Talk power required to speak in current channel.
|
||
final int? neededTalkPower;
|
||
|
||
/// True when server granted talk power regardless of numeric value.
|
||
final bool? talkPowerGranted;
|
||
|
||
/// Open the voice details modal.
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l10n = AppL10n.of(context);
|
||
|
||
final summary = voiceStatusSummary(
|
||
l10n: l10n,
|
||
transmitMode: transmitMode,
|
||
releaseTailMs: releaseTailMs,
|
||
pttBoundKeyLabel: pttBoundKeyLabel,
|
||
isTouchOnly: isTouchOnly,
|
||
inputMuted: inputMuted,
|
||
outputMuted: outputMuted,
|
||
pttActive: audioStats?.pttActive ?? false,
|
||
talkPower: talkPower,
|
||
neededTalkPower: neededTalkPower,
|
||
talkPowerGranted: talkPowerGranted,
|
||
);
|
||
|
||
return Semantics(
|
||
button: true,
|
||
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
|
||
hint: l10n.voiceSettingsTitle,
|
||
child: Material(
|
||
type: MaterialType.transparency,
|
||
child: InkWell(
|
||
onTap: () {
|
||
HapticFeedback.lightImpact();
|
||
onTap();
|
||
},
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: ExcludeSemantics(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
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: Row(
|
||
children: [
|
||
Icon(
|
||
summary.micOn
|
||
? Icons.fiber_manual_record
|
||
: Icons.fiber_manual_record_outlined,
|
||
size: 12,
|
||
color: summary.micOn
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.outline,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
summary.line1,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
Text(
|
||
summary.line2,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Icon(
|
||
Icons.expand_less,
|
||
size: 18,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Wide, bottom-anchored push-to-talk button.
|
||
class VoicePttButton extends StatefulWidget {
|
||
/// Construct a PTT button.
|
||
const VoicePttButton({
|
||
super.key,
|
||
required this.active,
|
||
required this.onHeldChanged,
|
||
this.height = 56,
|
||
this.borderRadius = 16,
|
||
this.iconSize = 28,
|
||
this.iconGap = 12,
|
||
this.blurRadius = 16,
|
||
this.spreadRadius = 2,
|
||
this.listenForPan = false,
|
||
this.labelLetterSpacing = 0.4,
|
||
});
|
||
|
||
/// True while the engine reports the gate open.
|
||
final bool active;
|
||
|
||
/// Called with `true` on finger-down, `false` on finger-up or cancel.
|
||
final ValueChanged<bool> onHeldChanged;
|
||
|
||
/// Button height.
|
||
final double height;
|
||
|
||
/// Outer corner radius.
|
||
final double borderRadius;
|
||
|
||
/// Mic icon size.
|
||
final double iconSize;
|
||
|
||
/// Space between icon and label.
|
||
final double iconGap;
|
||
|
||
/// Active-state shadow blur radius.
|
||
final double blurRadius;
|
||
|
||
/// Active-state shadow spread radius.
|
||
final double spreadRadius;
|
||
|
||
/// Also react to pan start/end/cancel in addition to tap gestures.
|
||
final bool listenForPan;
|
||
|
||
/// Optional label letter spacing.
|
||
final double? labelLetterSpacing;
|
||
|
||
@override
|
||
State<VoicePttButton> createState() => _VoicePttButtonState();
|
||
}
|
||
|
||
class _VoicePttButtonState extends State<VoicePttButton> {
|
||
bool _pressed = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
prepareVoiceHaptics();
|
||
}
|
||
|
||
void _setHeld(bool held) {
|
||
if (_pressed == held) return;
|
||
setState(() => _pressed = held);
|
||
widget.onHeldChanged(held);
|
||
playVoicePttHaptic(held);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l10n = AppL10n.of(context);
|
||
final activeNow = _pressed || widget.active;
|
||
|
||
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: widget.listenForPan ? (_) => _setHeld(true) : null,
|
||
onPanEnd: widget.listenForPan ? (_) => _setHeld(false) : null,
|
||
onPanCancel: widget.listenForPan ? () => _setHeld(false) : null,
|
||
child: ExcludeSemantics(
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 80),
|
||
height: widget.height,
|
||
decoration: BoxDecoration(
|
||
color: activeNow
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.primaryContainer,
|
||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||
boxShadow: activeNow
|
||
? [
|
||
BoxShadow(
|
||
color: theme.colorScheme.primary.withAlpha(100),
|
||
blurRadius: widget.blurRadius,
|
||
spreadRadius: widget.spreadRadius,
|
||
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: widget.iconSize,
|
||
),
|
||
SizedBox(width: widget.iconGap),
|
||
Text(
|
||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: widget.labelLetterSpacing,
|
||
color: activeNow
|
||
? theme.colorScheme.onPrimary
|
||
: theme.colorScheme.onPrimaryContainer,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Show the voice controls modal sheet — the single voice-controls
|
||
/// surface on mobile. Tiles:
|
||
/// 1. Audio output route picker (iOS native AVRoutePickerView /
|
||
/// Android Material 3 list). Mobile only.
|
||
/// 2. Mode radio buttons (PTT / Continuous / Voice Activity).
|
||
/// 3. Release-tail slider (PTT-only).
|
||
/// 4. Mic level meter + frame counts.
|
||
/// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes.
|
||
/// 6. PTT capability badge.
|
||
///
|
||
/// Mode + release-tail are inlined directly here instead of being
|
||
/// hidden behind an "Adjust" button → nested dialog. Single-screen
|
||
/// control panel, zero navigation depth. `onModeChanged` and
|
||
/// `onReleaseTailChanged` are debounced by the caller so users can
|
||
/// drag the slider freely.
|
||
Future<void> showVoiceDetailsSheet(
|
||
BuildContext context, {
|
||
required rust.BridgeTransmitMode transmitMode,
|
||
required int releaseTailMs,
|
||
required String pttBoundKeyLabel,
|
||
required String pttLevel,
|
||
required String pttBackendId,
|
||
required String pttBoundInputClass,
|
||
required bool isTouchOnly,
|
||
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
||
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
||
required ValueChanged<int> onReleaseTailChanged,
|
||
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
||
int? talkPower,
|
||
int? neededTalkPower,
|
||
bool? talkPowerGranted,
|
||
}) async {
|
||
await showModalBottomSheet<void>(
|
||
context: context,
|
||
showDragHandle: true,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (ctx) {
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.6,
|
||
minChildSize: 0.3,
|
||
maxChildSize: 0.95,
|
||
expand: false,
|
||
snap: true,
|
||
snapSizes: const [0.3, 0.6, 0.95],
|
||
builder: (ctx, scrollController) => _VoiceSheetBody(
|
||
scrollController: scrollController,
|
||
initialMode: transmitMode,
|
||
initialReleaseTailMs: releaseTailMs,
|
||
pttBoundKeyLabel: pttBoundKeyLabel,
|
||
pttLevel: pttLevel,
|
||
pttBackendId: pttBackendId,
|
||
pttBoundInputClass: pttBoundInputClass,
|
||
isTouchOnly: isTouchOnly,
|
||
initialAudioConfig: initialAudioConfig,
|
||
onModeChanged: onModeChanged,
|
||
onReleaseTailChanged: onReleaseTailChanged,
|
||
onAudioConfigChanged: onAudioConfigChanged,
|
||
talkPower: talkPower,
|
||
neededTalkPower: neededTalkPower,
|
||
talkPowerGranted: talkPowerGranted,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
class _VoiceSheetBody extends StatefulWidget {
|
||
const _VoiceSheetBody({
|
||
required this.scrollController,
|
||
required this.initialMode,
|
||
required this.initialReleaseTailMs,
|
||
required this.pttBoundKeyLabel,
|
||
required this.pttLevel,
|
||
required this.pttBackendId,
|
||
required this.pttBoundInputClass,
|
||
required this.isTouchOnly,
|
||
required this.initialAudioConfig,
|
||
required this.onModeChanged,
|
||
required this.onReleaseTailChanged,
|
||
required this.onAudioConfigChanged,
|
||
this.talkPower,
|
||
this.neededTalkPower,
|
||
this.talkPowerGranted,
|
||
});
|
||
|
||
final ScrollController scrollController;
|
||
final rust.BridgeTransmitMode initialMode;
|
||
final int initialReleaseTailMs;
|
||
final String pttBoundKeyLabel;
|
||
final String pttLevel;
|
||
final String pttBackendId;
|
||
final String pttBoundInputClass;
|
||
final bool isTouchOnly;
|
||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
||
final ValueChanged<int> onReleaseTailChanged;
|
||
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
||
final int? talkPower;
|
||
final int? neededTalkPower;
|
||
final bool? talkPowerGranted;
|
||
|
||
@override
|
||
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
|
||
}
|
||
|
||
class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||
late rust.BridgeTransmitMode _mode = widget.initialMode;
|
||
late int _tail = widget.initialReleaseTailMs;
|
||
|
||
// Live stats — polled by this widget's own timer so TX/RX update
|
||
// in real time while the sheet is open, independent of the parent.
|
||
rust.BridgeAudioStats? _stats;
|
||
Timer? _statsTimer;
|
||
// Previous snapshot for computing per-second rates.
|
||
int _prevSent = 0;
|
||
int _prevReceived = 0;
|
||
int _txRate = 0; // frames/s
|
||
int _rxRate = 0; // frames/s
|
||
int _rateTickCount = 0;
|
||
|
||
late final AudioProcessingConfigState _audioProcessing;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||
widget.initialAudioConfig,
|
||
);
|
||
|
||
// Poll audio stats at 250 ms so TX/RX counters and the level meter
|
||
// update in real time while the sheet is open, independent of the parent.
|
||
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
||
try {
|
||
final s = await rust.audioStats();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_stats = s;
|
||
_rateTickCount++;
|
||
// Compute rates every ~1 s (4 × 250 ms).
|
||
if (_rateTickCount >= 4) {
|
||
_txRate = s.framesSent - _prevSent;
|
||
_rxRate = s.framesReceived - _prevReceived;
|
||
_prevSent = s.framesSent;
|
||
_prevReceived = s.framesReceived;
|
||
_rateTickCount = 0;
|
||
}
|
||
});
|
||
} catch (_) {}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_statsTimer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||
return _audioProcessing.buildConfig(base: widget.initialAudioConfig);
|
||
}
|
||
|
||
void _notifyAudioConfig() {
|
||
widget.onAudioConfigChanged(_buildConfig());
|
||
}
|
||
|
||
void _setMode(rust.BridgeTransmitMode m) {
|
||
if (m == _mode) return;
|
||
setState(() => _mode = m);
|
||
unawaited(Haptics.selection().catchError((_) {}));
|
||
widget.onModeChanged(m);
|
||
}
|
||
|
||
void _setTail(double v) {
|
||
final ms = v.round();
|
||
setState(() => _tail = ms);
|
||
widget.onReleaseTailChanged(ms);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l10n = AppL10n.of(context);
|
||
final stats = _stats;
|
||
// Level meter active = transmitting (any mode).
|
||
final levelActive = switch (_mode) {
|
||
rust.BridgeTransmitMode.continuous => true,
|
||
_ => stats?.pttActive ?? false,
|
||
};
|
||
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
|
||
|
||
// Route picker is mobile-only. iOS uses AVAudioSession below;
|
||
// Android uses AudioManager through a MethodChannel.
|
||
final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||
|
||
return SafeArea(
|
||
child: SingleChildScrollView(
|
||
controller: widget.scrollController,
|
||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
|
||
const SizedBox(height: 12),
|
||
|
||
// 1) Audio output route picker tile (mobile only).
|
||
if (showRoutePicker) ...[
|
||
const AudioOutputTile(),
|
||
const SizedBox(height: 8),
|
||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||
const SizedBox(height: 8),
|
||
],
|
||
|
||
// Transmit mode.
|
||
Text(
|
||
l10n.voiceModeLabel,
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
_ModeRow(
|
||
label: l10n.voiceModePtt,
|
||
icon: Icons.radio_button_checked,
|
||
selected: _mode == rust.BridgeTransmitMode.ptt,
|
||
onTap: () => _setMode(rust.BridgeTransmitMode.ptt),
|
||
),
|
||
_ModeRow(
|
||
label: l10n.voiceModeContinuous,
|
||
icon: Icons.podcasts,
|
||
selected: _mode == rust.BridgeTransmitMode.continuous,
|
||
onTap: () => _setMode(rust.BridgeTransmitMode.continuous),
|
||
),
|
||
_ModeRow(
|
||
label: l10n.voiceModeVoiceActivity,
|
||
icon: Icons.graphic_eq,
|
||
selected: _mode == rust.BridgeTransmitMode.voiceActivity,
|
||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||
),
|
||
|
||
// 3) Release-tail slider (PTT only).
|
||
if (isPtt) ...[
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
l10n.voiceReleaseTailLabel,
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'$_tail${l10n.voiceReleaseTailHint}',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Slider(
|
||
value: _tail.toDouble().clamp(0, 500),
|
||
min: 0,
|
||
max: 500,
|
||
divisions: 20,
|
||
label: '$_tail ms',
|
||
onChanged: _setTail,
|
||
),
|
||
// Desktop-only: surface the bound key so the user
|
||
// sees what hardware key is wired. On mobile this
|
||
// row is suppressed (there is no hardware key; the
|
||
// PTT button is the input).
|
||
if (!widget.isTouchOnly &&
|
||
widget.pttBoundKeyLabel.isNotEmpty) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.only(left: 4),
|
||
child: Text(
|
||
'${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
|
||
const SizedBox(height: 16),
|
||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||
const SizedBox(height: 12),
|
||
|
||
// 4) Level meter + live TX/RX stats.
|
||
VoiceLevelMeter(active: levelActive),
|
||
const SizedBox(height: 6),
|
||
_StatsRow(
|
||
txRate: _txRate,
|
||
rxRate: _rxRate,
|
||
totalSent: stats?.framesSent ?? 0,
|
||
totalReceived: stats?.framesReceived ?? 0,
|
||
transmitting: levelActive,
|
||
),
|
||
|
||
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,
|
||
),
|
||
],
|
||
|
||
// Audio processing.
|
||
const SizedBox(height: 12),
|
||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Audio processing',
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
|
||
// Android HW/SW selector.
|
||
if (Platform.isAndroid) ...[
|
||
const VoiceSubHeader('Processing backend'),
|
||
SegmentedButton<bool>(
|
||
style: voiceSegmentedButtonStyle(theme),
|
||
segments: androidProcessingSegments,
|
||
selected: {_audioProcessing.preferHardware},
|
||
onSelectionChanged: (s) {
|
||
setState(() => _audioProcessing.preferHardware = s.first);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
_audioProcessing.preferHardware
|
||
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
|
||
: 'Software mode applies the full WebRTC APM stage set.',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
|
||
if (_isIos) ...[
|
||
Text(
|
||
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
|
||
'hidden here; only settings that still affect the shipping '
|
||
'iOS path are shown.',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
],
|
||
if (!_isIos &&
|
||
(!Platform.isAndroid ||
|
||
androidShowsNsControl(_audioProcessing)))
|
||
AudioProcessingToggleRow(
|
||
dense: true,
|
||
label: 'Noise suppression',
|
||
subtitle: 'Wiener filter',
|
||
value: _audioProcessing.nsEnabled,
|
||
onChanged: (v) {
|
||
setState(() => _audioProcessing.nsEnabled = v);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
if (!_isIos &&
|
||
(!Platform.isAndroid ||
|
||
androidShowsAecControl(_audioProcessing)))
|
||
AudioProcessingToggleRow(
|
||
dense: true,
|
||
label: 'Echo cancellation',
|
||
subtitle: Platform.isAndroid
|
||
? (_audioProcessing.preferHardware
|
||
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
|
||
: 'WebRTC AEC3 · adaptive filter')
|
||
: (_isMacOS
|
||
? 'Managed by platform VPIO'
|
||
: 'WebRTC AEC3 · adaptive filter'),
|
||
value: _isMacOS ? true : _audioProcessing.aecEnabled,
|
||
onChanged: _isMacOS
|
||
? null
|
||
: (v) {
|
||
setState(() => _audioProcessing.aecEnabled = v);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
if (!_isIos &&
|
||
(!Platform.isAndroid ||
|
||
androidShowsAgcControl(_audioProcessing)))
|
||
AudioProcessingToggleRow(
|
||
dense: true,
|
||
label: 'Auto gain control',
|
||
subtitle: 'AGC2 · -18 dBFS target',
|
||
value: _audioProcessing.agcEnabled,
|
||
onChanged: (v) {
|
||
setState(() => _audioProcessing.agcEnabled = v);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
|
||
AudioProcessingToggleRow(
|
||
dense: true,
|
||
label: 'High-pass filter',
|
||
subtitle: '80 Hz · DC removal',
|
||
value: _audioProcessing.hpfEnabled,
|
||
onChanged: (v) {
|
||
setState(() => _audioProcessing.hpfEnabled = v);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
if (!_isIos &&
|
||
(!Platform.isAndroid ||
|
||
androidShowsLimiterControl(_audioProcessing)))
|
||
AudioProcessingToggleRow(
|
||
dense: true,
|
||
label: 'Peak limiter',
|
||
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
|
||
value: _audioProcessing.limiterEnabled,
|
||
onChanged: (v) {
|
||
setState(() => _audioProcessing.limiterEnabled = v);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
|
||
// VAD backend.
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Voice activity detection (VAD)',
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
SegmentedButton<rust.BridgeVadBackend>(
|
||
style: voiceSegmentedButtonStyle(theme),
|
||
segments: _isDesktopSileroVadHost
|
||
? desktopVadBackendSegments
|
||
: vadBackendSegments,
|
||
selected: {_audioProcessing.vadBackend},
|
||
onSelectionChanged: (s) {
|
||
setState(() => _audioProcessing.vadBackend = s.first);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
|
||
// Debug.
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Debug',
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
AudioProcessingToggleRow(
|
||
dense: true,
|
||
label: 'WAV dump',
|
||
subtitle: 'Record raw/processed mic to temp dir',
|
||
value: _audioProcessing.debugWavDump,
|
||
onChanged: (v) {
|
||
setState(() => _audioProcessing.debugWavDump = v);
|
||
_notifyAudioConfig();
|
||
},
|
||
),
|
||
|
||
// 6) PTT capability badge. On iOS this must remain
|
||
// visible even though the resolved level is always
|
||
// `L0Focused`, because the P0 acceptance flow requires
|
||
// honest capability advertising with an explanation of
|
||
// the sandbox limitation.
|
||
if (isPtt) ...[
|
||
const SizedBox(height: 12),
|
||
PttCapabilityBadge(
|
||
level: widget.pttLevel,
|
||
backendId: widget.pttBackendId,
|
||
boundInputClass: widget.pttBoundInputClass,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Live TX/RX stats row ──────────────────────────────────────────────────
|
||
|
||
class _StatsRow extends StatelessWidget {
|
||
const _StatsRow({
|
||
required this.txRate,
|
||
required this.rxRate,
|
||
required this.totalSent,
|
||
required this.totalReceived,
|
||
required this.transmitting,
|
||
});
|
||
|
||
final int txRate;
|
||
final int rxRate;
|
||
final int totalSent;
|
||
final int totalReceived;
|
||
final bool transmitting;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final txColor = transmitting
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.onSurfaceVariant;
|
||
return Row(
|
||
children: [
|
||
// TX
|
||
Icon(Icons.upload, size: 12, color: txColor),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
'TX $txRate/s · $totalSent',
|
||
style: theme.textTheme.bodySmall?.copyWith(color: txColor),
|
||
),
|
||
const SizedBox(width: 12),
|
||
// RX
|
||
Icon(
|
||
Icons.download,
|
||
size: 12,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
'RX $rxRate/s · $totalReceived',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Audio processing helper widgets ──────────────────────────────────────
|
||
|
||
// ── Mode row ──────────────────────────────────────────────────────────────
|
||
|
||
class _ModeRow extends StatelessWidget {
|
||
const _ModeRow({
|
||
required this.label,
|
||
required this.icon,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
final String label;
|
||
final IconData icon;
|
||
final bool selected;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final disabled = onTap == null;
|
||
final color = disabled
|
||
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
|
||
: selected
|
||
? theme.colorScheme.primary
|
||
: theme.colorScheme.onSurface;
|
||
return Semantics(
|
||
button: true,
|
||
selected: selected,
|
||
enabled: !disabled,
|
||
label: label,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: ExcludeSemantics(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
selected
|
||
? Icons.radio_button_checked
|
||
: Icons.radio_button_unchecked,
|
||
size: 20,
|
||
color: color,
|
||
),
|
||
const SizedBox(width: 12),
|
||
Icon(icon, size: 18, color: color),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: theme.textTheme.bodyLarge?.copyWith(
|
||
color: color,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|