Compare commits
1
Commits
review-pr-21
...
pr-22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0060f3c19 |
@@ -1126,14 +1126,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
Future<void> _toggleOutputMute() async {
|
||||
final next = !_outputMuted;
|
||||
// Optimistic: flip the UI immediately.
|
||||
setState(() {
|
||||
_outputMuted = next;
|
||||
});
|
||||
try {
|
||||
await rust.setOutputMuted(muted: next);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_outputMuted = next;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// Roll back on failure.
|
||||
setState(() {
|
||||
_outputMuted = !next;
|
||||
});
|
||||
_showUiError('output mute', e);
|
||||
}
|
||||
}
|
||||
@@ -1260,6 +1264,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
return;
|
||||
}
|
||||
final next = !_hardMute;
|
||||
// Optimistic: flip the UI immediately so the icon responds
|
||||
// before the two bridge calls round-trip through FFI.
|
||||
setState(() {
|
||||
_inputMuted = next;
|
||||
_hardMute = next;
|
||||
_hardMuteByPermission = false;
|
||||
});
|
||||
try {
|
||||
// Hard-mute is two coordinated effects:
|
||||
// * setHardMute — local TransmitGate clamp; we stop sending
|
||||
@@ -1272,14 +1283,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
// through. Drive them together.
|
||||
await rust.setHardMute(muted: next);
|
||||
await rust.setInputMuted(muted: next);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_inputMuted = next;
|
||||
_hardMute = next;
|
||||
_hardMuteByPermission = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// Roll back on failure.
|
||||
setState(() {
|
||||
_inputMuted = !next;
|
||||
_hardMute = !next;
|
||||
});
|
||||
_showUiError('hard mute', e);
|
||||
}
|
||||
}
|
||||
@@ -2145,26 +2155,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final headerActions = [
|
||||
if (_serverReachable && _inChannel) ...[
|
||||
IconButton(
|
||||
tooltip: _hardMuteByTalkPower
|
||||
? 'Insufficient talk power to speak in this channel'
|
||||
: l10n.voiceHardMuteLabel,
|
||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: _hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
color: _hardMute ? theme.colorScheme.error : null,
|
||||
onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceOutputMuteLabel,
|
||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: _outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
color: _outputMuted ? theme.colorScheme.error : null,
|
||||
onPressed: _toggleOutputMute,
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
tooltip: l10n.aboutAction,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
@@ -2449,7 +2439,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
Expanded(child: snapshotView),
|
||||
const SizedBox(height: 8),
|
||||
permissionBanner,
|
||||
VoiceStatusChip(
|
||||
CompactVoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
@@ -2457,19 +2448,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
onToggleInputMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onOpenDetails: () => _onOpenVoiceDetailsSheet(),
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
talkPower: ownClientState?.talkPower,
|
||||
neededTalkPower: ownClientState?.neededTalkPower,
|
||||
talkPowerGranted: ownClientState?.talkPowerGranted,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
hardMuteByTalkPower: _hardMuteByTalkPower,
|
||||
),
|
||||
if (_inChannel &&
|
||||
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// 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).
|
||||
// Compact voice UI for narrow / mobile layouts.
|
||||
//
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -539,6 +543,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
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(),
|
||||
@@ -574,7 +580,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
|
||||
),
|
||||
|
||||
// 3) Release-tail slider (PTT only).
|
||||
// Release-tail slider (PTT only).
|
||||
if (isPtt) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -624,7 +630,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 4) Level meter + live TX/RX stats.
|
||||
// Level meter + live TX/RX stats.
|
||||
VoiceLevelMeter(active: levelActive),
|
||||
const SizedBox(height: 6),
|
||||
_StatsRow(
|
||||
@@ -648,183 +654,227 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
),
|
||||
],
|
||||
|
||||
// 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(
|
||||
// ── 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),
|
||||
],
|
||||
),
|
||||
|
||||
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(
|
||||
// ── 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,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
children: [
|
||||
_buildDebugSection(theme),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────
|
||||
@@ -948,3 +998,424 @@ class _ModeRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user