fix: improve voice and chat controls

This commit is contained in:
Edison Jwa
2026-05-25 18:26:56 +09:00
parent d03ea937e6
commit 487aff4e7c
13 changed files with 351 additions and 147 deletions
+73 -29
View File
@@ -174,6 +174,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
bool _hardMute = false;
bool _hardMuteByPermission = false;
bool _hardMuteByTalkPower = false;
bool _hardMuteByOutputMute = false;
bool _permissionHardMuteClearInFlight = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
@@ -212,6 +213,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
int _chatUnread = 0;
bool _chatOpen = false;
bool _chatPreviewSnackBarVisible = false;
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
@@ -484,8 +486,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setHardMute(muted: false);
if (!mounted || !_hardMuteByPermission) return;
setState(() {
_hardMute = false;
_hardMuteByPermission = false;
if (!_inputMuted && !_hardMuteByTalkPower && !_hardMuteByOutputMute) {
_hardMute = false;
}
});
} catch (e) {
if (!mounted) return;
@@ -1010,6 +1014,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!mounted) return;
setState(() {
_outputMuted = next;
_hardMuteByOutputMute = next;
if (next) {
_hardMute = true;
} else if (!_inputMuted &&
!_hardMuteByPermission &&
!_hardMuteByTalkPower) {
_hardMute = false;
}
});
} catch (e) {
if (!mounted) return;
@@ -1104,8 +1116,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
Future<void> _onToggleHardMute() async {
// Don't allow manual unmute when talk-power-muted.
if (_hardMuteByTalkPower && _hardMute) {
// Don't allow manual unmute while another policy owns the local
// transmit clamp.
if ((_hardMuteByTalkPower || _hardMuteByOutputMute) && _hardMute) {
return;
}
final next = !_hardMute;
@@ -1200,6 +1213,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
pttBoundInputClass: _pttBoundInputClass,
isTouchOnly: isTouchOnlyPttHost,
initialAudioConfig: audioConfig,
onnxRuntimeAvailable:
StartupDependencyScope.maybeOf(context)?.hasOnnxRuntime ?? true,
onModeChanged: (mode) async {
try {
await rust.setTransmitMode(mode: mode);
@@ -1241,6 +1256,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
initialMode: _transmitMode,
initialReleaseTailMs: _releaseTailMs,
initialAudioConfig: audioConfig,
onnxRuntimeAvailable:
StartupDependencyScope.maybeOf(ctx)?.hasOnnxRuntime ?? true,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
@@ -1399,6 +1416,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_audioStats = null;
_inputMuted = false;
_outputMuted = false;
_hardMute = false;
_hardMuteByPermission = false;
_hardMuteByTalkPower = false;
_hardMuteByOutputMute = false;
_inChannel = false;
_currentVoiceChannelId = null;
_pendingVoiceChannelId = null;
@@ -1418,6 +1439,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
String clientName = '',
}) async {
final initialSnapshot = _snapshot!;
_dismissChatPreviewSnackBar();
setState(() {
_chatUnread = 0;
_chatOpen = true;
@@ -1500,36 +1522,50 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
_chatPreviewSnackBarVisible = true;
void openChatFromPreview() {
_dismissChatPreviewSnackBar();
unawaited(
_onOpenChat(
target: target,
clientName: _chatClientNameForTarget(target, senderName),
),
);
}
final controller = messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(seconds: 7),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$senderName (${_chatTargetLabel(target)})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
],
),
action: SnackBarAction(
label: 'Open',
onPressed: () => unawaited(
_onOpenChat(
target: target,
clientName: _chatClientNameForTarget(target, senderName),
),
duration: const Duration(seconds: 4),
content: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: openChatFromPreview,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$senderName (${_chatTargetLabel(target)})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
],
),
),
action: SnackBarAction(label: 'Open', onPressed: openChatFromPreview),
),
);
controller.closed.then((_) => _chatPreviewSnackBarVisible = false);
}
void _dismissChatPreviewSnackBar() {
if (!_chatPreviewSnackBarVisible || !mounted) return;
_chatPreviewSnackBarVisible = false;
ScaffoldMessenger.of(context).removeCurrentSnackBar();
}
EdgeInsetsGeometry _chatSnackBarMargin() {
@@ -1589,6 +1625,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (own == null) return;
_inputMuted = own.inputMuted;
_outputMuted = own.outputMuted;
_hardMuteByOutputMute = own.outputMuted;
if (own.outputMuted) {
_hardMute = true;
} else if (!_inputMuted &&
!_hardMuteByPermission &&
!_hardMuteByTalkPower) {
_hardMute = false;
}
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
_currentVoiceChannelId = own.channelId;
_pendingVoiceChannelId = null;
@@ -1607,7 +1651,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
rust.setInputMuted(muted: true);
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
_hardMuteByTalkPower = false;
if (!_hardMuteByPermission) {
if (!_hardMuteByPermission && !_hardMuteByOutputMute && !_inputMuted) {
_hardMute = false;
rust.setHardMute(muted: false);
rust.setInputMuted(muted: false);
@@ -1622,12 +1666,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final rustText = rust.exportDiagnostics();
final rustText = await rust.exportDiagnostics();
if (!context.mounted) return;
final uiText = _uiDiagnostics.isEmpty
? 'UI diagnostics: none'
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
final text = '$uiText\n\nRust diagnostics:\n$rustText';
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
@@ -16,6 +16,7 @@ class StartupDependencyCheckResult {
bool get hasIssues => issues.isNotEmpty;
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
}
class StartupDependencyIssue {
@@ -207,12 +207,18 @@ rust.BridgeVadBackend normalizedVadBackend(
rust.BridgeVadBackend backend, {
bool? isWindows,
bool? isLinux,
bool onnxRuntimeAvailable = true,
}) {
final normalized = backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.sileroOnnx
: backend;
final desktop =
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
if (desktop &&
!onnxRuntimeAvailable &&
normalized == rust.BridgeVadBackend.sileroOnnx) {
return rust.BridgeVadBackend.webrtcVad;
}
if (!desktop) {
return normalized;
}
@@ -65,6 +65,7 @@ class _ConnectFormState extends State<ConnectForm> {
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _nickFocus.requestFocus(),
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
@@ -89,6 +90,7 @@ class _ConnectFormState extends State<ConnectForm> {
focusNode: _nickFocus,
onTapOutside: _onTapOutside,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
@@ -103,6 +105,7 @@ class _ConnectFormState extends State<ConnectForm> {
onTapOutside: _onTapOutside,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => widget.onConnect(),
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
@@ -88,6 +88,7 @@ class _SnapshotViewState extends State<SnapshotView> {
static const _channelIconColumnWidth = 24.0;
static const _channelTextGap = 8.0;
static const _userRowStartIndent = 32.0;
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
@@ -311,6 +312,7 @@ class _SnapshotViewState extends State<SnapshotView> {
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
Offset? secondaryTapPosition;
return Padding(
padding: EdgeInsets.only(
@@ -320,12 +322,15 @@ class _SnapshotViewState extends State<SnapshotView> {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
: null,
onSecondaryTapDown: canAdjustPlayback
? (details) => secondaryTapPosition = details.globalPosition
: null,
onSecondaryTap: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
? () => unawaited(
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
)
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
@@ -336,14 +341,6 @@ class _SnapshotViewState extends State<SnapshotView> {
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
trailing: canAdjustPlayback
? IconButton(
icon: const Icon(Icons.more_horiz),
tooltip: 'Playback options',
onPressed: () =>
unawaited(_showClientPlaybackSheet(client)),
)
: null,
),
),
),
@@ -401,8 +398,9 @@ class _SnapshotViewState extends State<SnapshotView> {
const ClientPlaybackPreference();
}
Future<void> _showClientPlaybackSheet(
Future<void> _showClientPlaybackMenu(
rust.BridgeClient client, {
Offset? anchor,
bool withHaptic = false,
}) async {
if (!_canAdjustClientPlayback(client)) return;
@@ -415,72 +413,52 @@ class _SnapshotViewState extends State<SnapshotView> {
}
if (!mounted) return;
var preference = _clientPlaybackPreference(client);
await showModalBottomSheet<void>(
final preference = _clientPlaybackPreference(client);
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final position = anchor ?? overlay.size.center(Offset.zero);
final selected = await showMenu<_ClientPlaybackMenuAction>(
context: context,
showDragHandle: true,
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
void updatePreference(ClientPlaybackPreference next) {
setModalState(() => preference = next);
unawaited(onChanged(client, next));
}
final volumePercent = (preference.volume * 100).round();
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
client.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Per-user playback',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SwitchListTile.adaptive(
key: const Key('client-playback-mute-tile'),
contentPadding: EdgeInsets.zero,
title: const Text('Mute playback'),
subtitle: Text(
preference.muted
? 'Audio from this user is muted on this server.'
: 'Audio from this user plays at $volumePercent%.',
),
value: preference.muted,
onChanged: (muted) =>
updatePreference(preference.copyWith(muted: muted)),
),
const SizedBox(height: 8),
Text(
'Volume $volumePercent%',
style: Theme.of(context).textTheme.labelLarge,
),
Slider(
key: const Key('client-playback-volume-slider'),
min: 0.0,
max: 4.0,
divisions: 40,
label: '$volumePercent%',
value: preference.volume,
onChanged: (value) =>
updatePreference(preference.copyWith(volume: value)),
),
],
),
),
);
},
);
},
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
overlay.size.width - position.dx,
overlay.size.height - position.dy,
),
items: [
PopupMenuItem<_ClientPlaybackMenuAction>(
value: const _ToggleMuteClientPlaybackMenuAction(),
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
preference.muted ? Icons.volume_off : Icons.volume_up,
),
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
subtitle: Text(client.name),
),
),
const PopupMenuDivider(),
..._clientPlaybackVolumePresets.map(
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
value: _VolumeClientPlaybackMenuAction(preset),
checked:
!preference.muted && (preference.volume - preset).abs() < 0.001,
child: Text('Volume ${(preset * 100).round()}%'),
),
),
],
);
if (selected == null || !mounted) return;
final next = switch (selected) {
_ToggleMuteClientPlaybackMenuAction() => preference.copyWith(
muted: !preference.muted,
),
_VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith(
volume: volume,
muted: false,
),
};
await onChanged(client, next);
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
@@ -695,6 +673,20 @@ class _ChannelTreeNode {
final List<_ChannelTreeNode> children = [];
}
sealed class _ClientPlaybackMenuAction {
const _ClientPlaybackMenuAction();
}
class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _ToggleMuteClientPlaybackMenuAction();
}
class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _VolumeClientPlaybackMenuAction(this.volume);
final double volume;
}
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
final byParent = <BigInt, List<rust.BridgeChannel>>{};
final knownIds = {for (final channel in channels) channel.id};
@@ -22,6 +22,27 @@ class StartupDependencyGate extends StatefulWidget {
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
}
class StartupDependencyScope extends InheritedWidget {
const StartupDependencyScope({
required this.result,
required super.child,
super.key,
});
final StartupDependencyCheckResult result;
static StartupDependencyCheckResult? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<StartupDependencyScope>()
?.result;
}
@override
bool updateShouldNotify(StartupDependencyScope oldWidget) {
return result != oldWidget.result;
}
}
class _StartupDependencyGateState extends State<StartupDependencyGate> {
late Future<StartupDependencyCheckResult> _future;
bool _dismissedForSession = false;
@@ -43,7 +64,20 @@ class _StartupDependencyGateState extends State<StartupDependencyGate> {
@override
Widget build(BuildContext context) {
if (_dismissedForSession) {
return widget.child;
return FutureBuilder<StartupDependencyCheckResult>(
future: _future,
builder: (context, snapshot) {
return StartupDependencyScope(
result:
snapshot.data ??
const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
),
child: widget.child,
);
},
);
}
return FutureBuilder<StartupDependencyCheckResult>(
@@ -56,18 +90,29 @@ class _StartupDependencyGateState extends State<StartupDependencyGate> {
final result = snapshot.data;
if (result == null || !result.hasIssues) {
_lastLoggedIssueSignature = null;
return widget.child;
return StartupDependencyScope(
result:
result ??
const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
),
child: widget.child,
);
}
_logIssueScreenShown(result);
return StartupDependencyScreen(
return StartupDependencyScope(
result: result,
onContinue: () {
setState(() {
_dismissedForSession = true;
});
},
onRecheck: _recheck,
child: StartupDependencyScreen(
result: result,
onContinue: () {
setState(() {
_dismissedForSession = true;
});
},
onRecheck: _recheck,
),
);
},
);
@@ -358,6 +358,7 @@ Future<void> showVoiceDetailsSheet(
required String pttBoundInputClass,
required bool isTouchOnly,
required rust.BridgeAudioProcessingConfig initialAudioConfig,
bool onnxRuntimeAvailable = true,
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
required ValueChanged<int> onReleaseTailChanged,
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
@@ -388,6 +389,7 @@ Future<void> showVoiceDetailsSheet(
pttBoundInputClass: pttBoundInputClass,
isTouchOnly: isTouchOnly,
initialAudioConfig: initialAudioConfig,
onnxRuntimeAvailable: onnxRuntimeAvailable,
onModeChanged: onModeChanged,
onReleaseTailChanged: onReleaseTailChanged,
onAudioConfigChanged: onAudioConfigChanged,
@@ -411,6 +413,7 @@ class _VoiceSheetBody extends StatefulWidget {
required this.pttBoundInputClass,
required this.isTouchOnly,
required this.initialAudioConfig,
required this.onnxRuntimeAvailable,
required this.onModeChanged,
required this.onReleaseTailChanged,
required this.onAudioConfigChanged,
@@ -428,6 +431,7 @@ class _VoiceSheetBody extends StatefulWidget {
final String pttBoundInputClass;
final bool isTouchOnly;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
final bool onnxRuntimeAvailable;
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
final ValueChanged<int> onReleaseTailChanged;
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
@@ -462,6 +466,10 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
_audioProcessing.vadBackend = normalizedVadBackend(
_audioProcessing.vadBackend,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
);
// 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.
@@ -777,19 +785,27 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
segments: vadBackendSegmentsForAvailability(
desktop: _isDesktopSileroVadHost,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);
setState(
() => _audioProcessing.vadBackend = normalizedVadBackend(
s.first,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
);
_notifyAudioConfig();
},
),
if (_isDesktopSileroVadHost) ...[
const SizedBox(height: 4),
Text(
'Silero needs ONNX Runtime. WebRTC works without it.',
widget.onnxRuntimeAvailable
? 'Silero needs ONNX Runtime. WebRTC works without it.'
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -67,6 +67,7 @@ class VoiceSettingsDialog extends StatefulWidget {
this.pttLevel = '',
this.pttBackendId = '',
this.pttBoundInputClass = '',
this.onnxRuntimeAvailable = true,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
@@ -78,6 +79,7 @@ class VoiceSettingsDialog extends StatefulWidget {
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final bool onnxRuntimeAvailable;
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@@ -101,6 +103,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
_audioProcessing.vadBackend = normalizedVadBackend(
_audioProcessing.vadBackend,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
);
}
rust.BridgeAudioProcessingConfig _buildConfig() {
@@ -291,17 +297,24 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
segments: vadBackendSegmentsForAvailability(
desktop: _isDesktopSileroVadHost,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
onSelectionChanged: (s) => setState(
() => _audioProcessing.vadBackend = normalizedVadBackend(
s.first,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
),
),
const SizedBox(height: 8),
if (_isDesktopSileroVadHost)
Text(
'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.',
widget.onnxRuntimeAvailable
? 'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.'
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected until libonnxruntime.so is installed or bundled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -64,6 +64,26 @@ const vadBackendSegments = [
/// smaller dependency surface.
const desktopVadBackendSegments = vadBackendSegments;
List<ButtonSegment<rust.BridgeVadBackend>> vadBackendSegmentsForAvailability({
required bool desktop,
required bool onnxRuntimeAvailable,
}) {
final segments = desktop ? desktopVadBackendSegments : vadBackendSegments;
if (!desktop || onnxRuntimeAvailable) return segments;
return [
for (final segment in segments)
if (segment.value == rust.BridgeVadBackend.sileroOnnx)
ButtonSegment<rust.BridgeVadBackend>(
value: segment.value,
label: segment.label,
icon: segment.icon,
enabled: false,
)
else
segment,
];
}
/// Section subheader used by both voice settings surfaces.
class VoiceSubHeader extends StatelessWidget {
/// Construct a voice settings subheader.
@@ -58,6 +58,21 @@ void main() {
);
});
test(
'desktop VAD normalization falls back when ONNX Runtime is unavailable',
() {
expect(
normalizedVadBackend(
rust.BridgeVadBackend.sileroOnnx,
isWindows: false,
isLinux: true,
onnxRuntimeAvailable: false,
),
rust.BridgeVadBackend.webrtcVad,
);
},
);
test('default config matches the current platform fallback', () {
final fallback = defaultAudioProcessingConfig();
final desktop = Platform.isWindows || Platform.isLinux;
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/widgets/connect_widgets.dart';
void main() {
testWidgets('pressing enter in password field submits connection', (
tester,
) async {
var connectCount = 0;
final hostCtl = TextEditingController(text: 'example.com');
final nickCtl = TextEditingController(text: 'alice');
final passwordCtl = TextEditingController();
addTearDown(hostCtl.dispose);
addTearDown(nickCtl.dispose);
addTearDown(passwordCtl.dispose);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ConnectForm(
hostCtl: hostCtl,
nickCtl: nickCtl,
passwordCtl: passwordCtl,
onConnect: () => connectCount += 1,
onAddBookmark: () {},
),
),
),
);
await tester.enterText(find.byType(TextField).last, 'secret');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(connectCount, 1);
});
}
@@ -270,7 +270,7 @@ void main() {
},
);
testWidgets('remote client playback options button opens playback sheet', (
testWidgets('remote client row does not show playback ellipsis', (
tester,
) async {
await tester.pumpWidget(
@@ -288,20 +288,12 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_horiz));
await tester.pumpAndSettle();
expect(find.text('Per-user playback'), findsOneWidget);
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 50%'), findsOneWidget);
expect(
find.byKey(const Key('client-playback-volume-slider')),
findsOneWidget,
);
expect(find.byIcon(Icons.more_horiz), findsNothing);
});
testWidgets('remote client long press opens playback sheet', (tester) async {
testWidgets('remote client long press opens playback menu', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
@@ -318,11 +310,11 @@ void main() {
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
expect(find.text('Per-user playback'), findsOneWidget);
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 100%'), findsOneWidget);
});
testWidgets('remote client secondary click opens playback sheet', (
testWidgets('remote client secondary click opens playback menu', (
tester,
) async {
await tester.pumpWidget(
@@ -344,11 +336,11 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Per-user playback'), findsOneWidget);
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 100%'), findsOneWidget);
});
testWidgets('client playback sheet forwards mute and volume changes', (
testWidgets('client playback menu forwards mute and volume changes', (
tester,
) async {
final changes = <ClientPlaybackPreference>[];
@@ -368,7 +360,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_horiz));
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Mute playback'));
@@ -377,13 +369,13 @@ void main() {
expect(changes, isNotEmpty);
expect(changes.last.muted, isTrue);
await tester.drag(
find.byKey(const Key('client-playback-volume-slider')),
const Offset(160, 0),
);
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Volume 200%'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(changes.last.volume, greaterThan(1.0));
expect(changes.last.volume, 2.0);
expect(changes.last.muted, isFalse);
});
testWidgets('spacer channels render as layout rows and keep channel taps', (
@@ -31,6 +31,22 @@ void main() {
]);
});
test('desktop VAD segments disable Silero when ONNX Runtime is missing', () {
final segments = vadBackendSegmentsForAvailability(
desktop: true,
onnxRuntimeAvailable: false,
);
final silero = segments.singleWhere(
(segment) => segment.value == rust.BridgeVadBackend.sileroOnnx,
);
final webrtc = segments.singleWhere(
(segment) => segment.value == rust.BridgeVadBackend.webrtcVad,
);
expect(silero.enabled, isFalse);
expect(webrtc.enabled, isTrue);
});
testWidgets('shared segmented style applies compact visual density', (
tester,
) async {