Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T
Edison Jwa e0060f3c19 feat(voice): unified mobile voice bar with gesture-isolated PTT row
Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode

Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)
2026-06-04 22:46:19 +09:00

1422 lines
47 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Compact voice UI for narrow / mobile layouts.
//
// Two-zone voice bar pinned to the bottom:
// • Control row: status text + mute + deafen + settings chevron
// • PTT row: full-width hold-to-talk (PTT mode only)
// Both share a single container whose background colour reflects
// the current voice state (normal / muted / talk-power-blocked).
//
// Gesture isolation: the control row uses tap-only InkWell /
// IconButton; the PTT row uses a raw Listener for pointer-down /
// pointer-up. Because each row is a disjoint hit-test region, a
// finger holding PTT cannot accidentally toggle mute or deafen.
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),
// ── Primary section (always visible) ────────────────────
// 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),
),
// 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),
// 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,
),
],
const SizedBox(height: 8),
// ── Collapsible: Audio processing ───────────────────────
ExpansionTile(
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
title: Text(
'Audio processing',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
children: [
_buildAudioProcessingSection(theme),
],
),
// ── Collapsible: PTT capability (PTT mode only) ─────────
if (isPtt)
ExpansionTile(
initiallyExpanded: false,
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
shape: const Border(),
collapsedShape: const Border(),
title: Text(
'PTT capability',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
children: [
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
const SizedBox(height: 8),
],
),
// ── Collapsible: Debug ──────────────────────────────────
ExpansionTile(
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
title: Text(
'Debug',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
children: [
_buildDebugSection(theme),
],
),
],
),
),
);
}
// ── Audio processing section (inside ExpansionTile) ─────────────────
Widget _buildAudioProcessingSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 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();
},
),
const SizedBox(height: 8),
],
);
}
// ── Debug section (inside ExpansionTile) ─────────────────────────────
Widget _buildDebugSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AudioProcessingToggleRow(
dense: true,
label: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _audioProcessing.debugWavDump,
onChanged: (v) {
setState(() => _audioProcessing.debugWavDump = v);
_notifyAudioConfig();
},
),
const SizedBox(height: 8),
],
);
}
}
// ── 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,
),
),
],
),
),
],
),
),
),
),
);
}
}
// ── Unified mobile voice bar ──────────────────────────────────────────────
/// A unified bottom-anchored voice bar for compact / mobile layouts.
///
/// Combines the former [VoiceStatusChip] and [VoicePttButton] into one
/// visual zone with two rows:
///
/// ┌──────────────────────────────────────────────┐
/// │ 🟢 PTT · Connected [🔇] [🎧] [▲] │ ← control row (tap)
/// ├──────────────────────────────────────────────┤
/// │ ════ hold to talk ════ │ ← PTT row (hold)
/// └──────────────────────────────────────────────┘
///
/// The PTT row is shown only when [transmitMode] is PTT; for continuous
/// or voice-activity modes the bar shrinks to the control row alone.
///
/// State colour is applied to the entire container:
/// - normal: `surfaceContainerHigh`
/// - muted: `errorContainer` (35 % alpha)
/// - talk-power-block: amber (18 % alpha)
class CompactVoiceBar extends StatelessWidget {
/// Construct a compact voice bar.
const CompactVoiceBar({
super.key,
required this.inChannel,
required this.transmitMode,
required this.releaseTailMs,
required this.pttBoundKeyLabel,
required this.audioStats,
required this.isTouchOnly,
required this.inputMuted,
required this.outputMuted,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onOpenDetails,
required this.onPttHeldChanged,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
this.hardMuteByTalkPower = false,
});
/// True when the client is inside a channel (gates PTT row visibility).
final bool inChannel;
/// 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.
final bool inputMuted;
/// True when local speaker is muted.
final bool outputMuted;
/// Toggle hard-mute on / off.
final VoidCallback onToggleInputMute;
/// Toggle output mute on / off.
final VoidCallback onToggleOutputMute;
/// Open the voice details modal sheet.
final VoidCallback onOpenDetails;
/// Called with `true` on finger-down, `false` on finger-up / cancel.
final ValueChanged<bool> onPttHeldChanged;
/// 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.
final bool? talkPowerGranted;
/// True when talk power prevents speaking.
final bool hardMuteByTalkPower;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
final pttActive = audioStats?.pttActive ?? false;
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: transmitMode,
releaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
isTouchOnly: isTouchOnly,
inputMuted: inputMuted,
outputMuted: outputMuted,
pttActive: pttActive,
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
);
// Container colour based on voice state.
final containerColor = summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh;
final borderColor = summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant;
final borderWidth = 1.0;
return Semantics(
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
child: Material(
type: MaterialType.transparency,
child: Container(
decoration: BoxDecoration(
color: containerColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: borderColor, width: borderWidth),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Control row ──────────────────────────────────────
_ControlRow(
summary: summary,
inputMuted: inputMuted,
outputMuted: outputMuted,
hardMuteByTalkPower: hardMuteByTalkPower,
onToggleInputMute: onToggleInputMute,
onToggleOutputMute: onToggleOutputMute,
onOpenDetails: onOpenDetails,
),
// ── PTT row (in-channel + PTT mode only) ──────────────
if (isPtt && inChannel) ...[
Divider(
height: 1,
thickness: 0.5,
color: borderColor,
indent: 12,
endIndent: 12,
),
_PttRow(
active: pttActive,
enabled: !hardMuteByTalkPower,
onHeldChanged: onPttHeldChanged,
),
],
],
),
),
),
),
);
}
}
// ── Control row (tap-only zone) ───────────────────────────────────────────
class _ControlRow extends StatelessWidget {
const _ControlRow({
required this.summary,
required this.inputMuted,
required this.outputMuted,
required this.hardMuteByTalkPower,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onOpenDetails,
});
final VoiceStatusSummary summary;
final bool inputMuted;
final bool outputMuted;
final bool hardMuteByTalkPower;
final VoidCallback onToggleInputMute;
final VoidCallback onToggleOutputMute;
final VoidCallback onOpenDetails;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Status dot.
Icon(
summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined,
size: 10,
color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline,
),
const SizedBox(width: 8),
// Status text (tappable → open details).
Expanded(
child: Semantics(
button: true,
label: summary.line1,
child: InkWell(
onTap: onOpenDetails,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
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: 4),
// Mute button.
_ToggleButton(
icon: inputMuted ? Icons.mic_off : Icons.mic,
isActive: inputMuted,
tooltip: 'Mute mic',
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
),
// Deafen button.
_ToggleButton(
icon: outputMuted ? Icons.headset_off : Icons.headset,
isActive: outputMuted,
tooltip: 'Deafen',
onPressed: onToggleOutputMute,
),
// Settings / expand chevron.
IconButton(
icon: Icon(Icons.expand_less, size: 20, color: theme.colorScheme.onSurfaceVariant),
tooltip: 'Voice settings',
onPressed: onOpenDetails,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
),
],
),
);
}
}
// ── Toggle button (mute / deafen) ─────────────────────────────────────────
class _ToggleButton extends StatelessWidget {
const _ToggleButton({
required this.icon,
required this.isActive,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool isActive;
final String tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: Icon(icon, size: 22),
tooltip: tooltip,
color: isActive ? theme.colorScheme.error : null,
onPressed: onPressed,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: EdgeInsets.zero,
);
}
}
// ── PTT row (hold-only zone) ──────────────────────────────────────────────
class _PttRow extends StatefulWidget {
const _PttRow({
required this.active,
required this.enabled,
required this.onHeldChanged,
});
/// True while the engine reports the gate open.
final bool active;
/// Whether the PTT button can be engaged.
final bool enabled;
/// Called with `true` on pointer-down, `false` on pointer-up / cancel.
final ValueChanged<bool> onHeldChanged;
@override
State<_PttRow> createState() => _PttRowState();
}
class _PttRowState extends State<_PttRow> {
int? _activePointer;
bool _held = false;
@override
void initState() {
super.initState();
prepareVoiceHaptics();
}
void _begin(PointerDownEvent event) {
if (!widget.enabled || _activePointer != null) return;
_activePointer = event.pointer;
_held = true;
widget.onHeldChanged(true);
playVoicePttHaptic(true);
setState(() {});
}
void _end(int pointer) {
if (_activePointer != pointer) return;
_activePointer = null;
_held = false;
widget.onHeldChanged(false);
playVoicePttHaptic(false);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final activeNow = _held || widget.active;
return Semantics(
button: true,
liveRegion: true,
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
hint: l10n.pttHoldToTalkSemanticsHint,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _begin,
onPointerUp: (e) => _end(e.pointer),
onPointerCancel: (e) => _end(e.pointer),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: Colors.transparent,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none_outlined,
size: 22,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
}