From 487aff4e7cdc9e01c97ca41c746e61ec0faa92b7 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 25 May 2026 18:26:56 +0900 Subject: [PATCH] fix: improve voice and chat controls --- apps/chanora_flutter/lib/main.dart | 102 ++++++++---- .../services/startup_dependency_check.dart | 1 + .../audio_processing_config_state.dart | 6 + .../lib/widgets/connect_widgets.dart | 3 + .../lib/widgets/snapshot_view.dart | 146 +++++++++--------- .../widgets/startup_dependency_screen.dart | 63 ++++++-- .../lib/widgets/voice_compact.dart | 26 +++- .../lib/widgets/voice_settings.dart | 25 ++- .../lib/widgets/voice_settings_controls.dart | 20 +++ .../audio_processing_config_state_test.dart | 15 ++ .../test/widgets/connect_widgets_test.dart | 41 +++++ .../test/widgets/snapshot_view_test.dart | 34 ++-- .../widgets/voice_settings_controls_test.dart | 16 ++ 13 files changed, 351 insertions(+), 147 deletions(-) create mode 100644 apps/chanora_flutter/test/widgets/connect_widgets_test.dart diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 92fb5a2..40eee16 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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 _chatFeedRevision = ValueNotifier(0); int _chatUnread = 0; bool _chatOpen = false; + bool _chatPreviewSnackBarVisible = false; final ValueNotifier> _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 _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 _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( context: context, builder: (ctx) => AlertDialog( diff --git a/apps/chanora_flutter/lib/services/startup_dependency_check.dart b/apps/chanora_flutter/lib/services/startup_dependency_check.dart index 9f6e740..28b16e3 100644 --- a/apps/chanora_flutter/lib/services/startup_dependency_check.dart +++ b/apps/chanora_flutter/lib/services/startup_dependency_check.dart @@ -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 { diff --git a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart index 72ccf32..6a030ca 100644 --- a/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart +++ b/apps/chanora_flutter/lib/widgets/audio_processing_config_state.dart @@ -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; } diff --git a/apps/chanora_flutter/lib/widgets/connect_widgets.dart b/apps/chanora_flutter/lib/widgets/connect_widgets.dart index 9165746..ddc4e5a 100644 --- a/apps/chanora_flutter/lib/widgets/connect_widgets.dart +++ b/apps/chanora_flutter/lib/widgets/connect_widgets.dart @@ -65,6 +65,7 @@ class _ConnectFormState extends State { 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 { focusNode: _nickFocus, onTapOutside: _onTapOutside, textInputAction: TextInputAction.next, + onSubmitted: (_) => _passwordFocus.requestFocus(), autocorrect: false, enableSuggestions: false, decoration: InputDecoration( @@ -103,6 +105,7 @@ class _ConnectFormState extends State { onTapOutside: _onTapOutside, obscureText: true, textInputAction: TextInputAction.done, + onSubmitted: (_) => widget.onConnect(), autocorrect: false, enableSuggestions: false, decoration: InputDecoration( diff --git a/apps/chanora_flutter/lib/widgets/snapshot_view.dart b/apps/chanora_flutter/lib/widgets/snapshot_view.dart index 3e11276..c2ffcb8 100644 --- a/apps/chanora_flutter/lib/widgets/snapshot_view.dart +++ b/apps/chanora_flutter/lib/widgets/snapshot_view.dart @@ -88,6 +88,7 @@ class _SnapshotViewState extends State { 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 _channelExpandedById = {}; @@ -311,6 +312,7 @@ class _SnapshotViewState extends State { ) : null; final canAdjustPlayback = _canAdjustClientPlayback(client); + Offset? secondaryTapPosition; return Padding( padding: EdgeInsets.only( @@ -320,12 +322,15 @@ class _SnapshotViewState extends State { 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 { 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 { const ClientPlaybackPreference(); } - Future _showClientPlaybackSheet( + Future _showClientPlaybackMenu( rust.BridgeClient client, { + Offset? anchor, bool withHaptic = false, }) async { if (!_canAdjustClientPlayback(client)) return; @@ -415,72 +413,52 @@ class _SnapshotViewState extends State { } if (!mounted) return; - var preference = _clientPlaybackPreference(client); - await showModalBottomSheet( + 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 channels) { final byParent = >{}; final knownIds = {for (final channel in channels) channel.id}; diff --git a/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart b/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart index c7972da..8d64d69 100644 --- a/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart +++ b/apps/chanora_flutter/lib/widgets/startup_dependency_screen.dart @@ -22,6 +22,27 @@ class StartupDependencyGate extends StatefulWidget { State 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() + ?.result; + } + + @override + bool updateShouldNotify(StartupDependencyScope oldWidget) { + return result != oldWidget.result; + } +} + class _StartupDependencyGateState extends State { late Future _future; bool _dismissedForSession = false; @@ -43,7 +64,20 @@ class _StartupDependencyGateState extends State { @override Widget build(BuildContext context) { if (_dismissedForSession) { - return widget.child; + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + return StartupDependencyScope( + result: + snapshot.data ?? + const StartupDependencyCheckResult( + issues: [], + platformLabel: 'default', + ), + child: widget.child, + ); + }, + ); } return FutureBuilder( @@ -56,18 +90,29 @@ class _StartupDependencyGateState extends State { 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, + ), ); }, ); diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 2e0c71a..7213c85 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -358,6 +358,7 @@ Future showVoiceDetailsSheet( required String pttBoundInputClass, required bool isTouchOnly, required rust.BridgeAudioProcessingConfig initialAudioConfig, + bool onnxRuntimeAvailable = true, required ValueChanged onModeChanged, required ValueChanged onReleaseTailChanged, required ValueChanged onAudioConfigChanged, @@ -388,6 +389,7 @@ Future 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 onModeChanged; final ValueChanged onReleaseTailChanged; final ValueChanged 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( 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, ), diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index e07554d..5d90967 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -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 { _audioProcessing = AudioProcessingConfigState.fromConfig( widget.initialAudioConfig, ); + _audioProcessing.vadBackend = normalizedVadBackend( + _audioProcessing.vadBackend, + onnxRuntimeAvailable: widget.onnxRuntimeAvailable, + ); } rust.BridgeAudioProcessingConfig _buildConfig() { @@ -291,17 +297,24 @@ class _VoiceSettingsDialogState extends State { const VoiceSubHeader('Backend'), SegmentedButton( 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, ), diff --git a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart index f18fb21..41cb657 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings_controls.dart @@ -64,6 +64,26 @@ const vadBackendSegments = [ /// smaller dependency surface. const desktopVadBackendSegments = vadBackendSegments; +List> 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( + 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. diff --git a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart index a415974..cb575c0 100644 --- a/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart +++ b/apps/chanora_flutter/test/widgets/audio_processing_config_state_test.dart @@ -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; diff --git a/apps/chanora_flutter/test/widgets/connect_widgets_test.dart b/apps/chanora_flutter/test/widgets/connect_widgets_test.dart new file mode 100644 index 0000000..1d3b620 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/connect_widgets_test.dart @@ -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); + }); +} diff --git a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart index dc71d12..54ffb93 100644 --- a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart +++ b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart @@ -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 = []; @@ -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', ( diff --git a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart index 6f3f525..c3d0493 100644 --- a/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart +++ b/apps/chanora_flutter/test/widgets/voice_settings_controls_test.dart @@ -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 {