fix: improve voice and chat controls
This commit is contained in:
@@ -174,6 +174,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
bool _hardMute = false;
|
bool _hardMute = false;
|
||||||
bool _hardMuteByPermission = false;
|
bool _hardMuteByPermission = false;
|
||||||
bool _hardMuteByTalkPower = false;
|
bool _hardMuteByTalkPower = false;
|
||||||
|
bool _hardMuteByOutputMute = false;
|
||||||
bool _permissionHardMuteClearInFlight = false;
|
bool _permissionHardMuteClearInFlight = false;
|
||||||
int _releaseTailMs = 200;
|
int _releaseTailMs = 200;
|
||||||
BigInt? _currentVoiceChannelId;
|
BigInt? _currentVoiceChannelId;
|
||||||
@@ -212,6 +213,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
||||||
int _chatUnread = 0;
|
int _chatUnread = 0;
|
||||||
bool _chatOpen = false;
|
bool _chatOpen = false;
|
||||||
|
bool _chatPreviewSnackBarVisible = false;
|
||||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
||||||
const [],
|
const [],
|
||||||
);
|
);
|
||||||
@@ -484,8 +486,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (!mounted || !_hardMuteByPermission) return;
|
if (!mounted || !_hardMuteByPermission) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = false;
|
|
||||||
_hardMuteByPermission = false;
|
_hardMuteByPermission = false;
|
||||||
|
if (!_inputMuted && !_hardMuteByTalkPower && !_hardMuteByOutputMute) {
|
||||||
|
_hardMute = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -1010,6 +1014,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_outputMuted = next;
|
_outputMuted = next;
|
||||||
|
_hardMuteByOutputMute = next;
|
||||||
|
if (next) {
|
||||||
|
_hardMute = true;
|
||||||
|
} else if (!_inputMuted &&
|
||||||
|
!_hardMuteByPermission &&
|
||||||
|
!_hardMuteByTalkPower) {
|
||||||
|
_hardMute = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -1104,8 +1116,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onToggleHardMute() async {
|
Future<void> _onToggleHardMute() async {
|
||||||
// Don't allow manual unmute when talk-power-muted.
|
// Don't allow manual unmute while another policy owns the local
|
||||||
if (_hardMuteByTalkPower && _hardMute) {
|
// transmit clamp.
|
||||||
|
if ((_hardMuteByTalkPower || _hardMuteByOutputMute) && _hardMute) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final next = !_hardMute;
|
final next = !_hardMute;
|
||||||
@@ -1200,6 +1213,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
pttBoundInputClass: _pttBoundInputClass,
|
pttBoundInputClass: _pttBoundInputClass,
|
||||||
isTouchOnly: isTouchOnlyPttHost,
|
isTouchOnly: isTouchOnlyPttHost,
|
||||||
initialAudioConfig: audioConfig,
|
initialAudioConfig: audioConfig,
|
||||||
|
onnxRuntimeAvailable:
|
||||||
|
StartupDependencyScope.maybeOf(context)?.hasOnnxRuntime ?? true,
|
||||||
onModeChanged: (mode) async {
|
onModeChanged: (mode) async {
|
||||||
try {
|
try {
|
||||||
await rust.setTransmitMode(mode: mode);
|
await rust.setTransmitMode(mode: mode);
|
||||||
@@ -1241,6 +1256,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
initialMode: _transmitMode,
|
initialMode: _transmitMode,
|
||||||
initialReleaseTailMs: _releaseTailMs,
|
initialReleaseTailMs: _releaseTailMs,
|
||||||
initialAudioConfig: audioConfig,
|
initialAudioConfig: audioConfig,
|
||||||
|
onnxRuntimeAvailable:
|
||||||
|
StartupDependencyScope.maybeOf(ctx)?.hasOnnxRuntime ?? true,
|
||||||
pttLevel: _pttLevel,
|
pttLevel: _pttLevel,
|
||||||
pttBackendId: _pttBackendId,
|
pttBackendId: _pttBackendId,
|
||||||
pttBoundInputClass: _pttBoundInputClass,
|
pttBoundInputClass: _pttBoundInputClass,
|
||||||
@@ -1399,6 +1416,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_audioStats = null;
|
_audioStats = null;
|
||||||
_inputMuted = false;
|
_inputMuted = false;
|
||||||
_outputMuted = false;
|
_outputMuted = false;
|
||||||
|
_hardMute = false;
|
||||||
|
_hardMuteByPermission = false;
|
||||||
|
_hardMuteByTalkPower = false;
|
||||||
|
_hardMuteByOutputMute = false;
|
||||||
_inChannel = false;
|
_inChannel = false;
|
||||||
_currentVoiceChannelId = null;
|
_currentVoiceChannelId = null;
|
||||||
_pendingVoiceChannelId = null;
|
_pendingVoiceChannelId = null;
|
||||||
@@ -1418,6 +1439,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
String clientName = '',
|
String clientName = '',
|
||||||
}) async {
|
}) async {
|
||||||
final initialSnapshot = _snapshot!;
|
final initialSnapshot = _snapshot!;
|
||||||
|
_dismissChatPreviewSnackBar();
|
||||||
setState(() {
|
setState(() {
|
||||||
_chatUnread = 0;
|
_chatUnread = 0;
|
||||||
_chatOpen = true;
|
_chatOpen = true;
|
||||||
@@ -1500,36 +1522,50 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
if (_pokeSnackBarPokes.value.isNotEmpty) return;
|
if (_pokeSnackBarPokes.value.isNotEmpty) return;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
messenger.hideCurrentSnackBar();
|
messenger.hideCurrentSnackBar();
|
||||||
messenger.showSnackBar(
|
_chatPreviewSnackBarVisible = true;
|
||||||
|
void openChatFromPreview() {
|
||||||
|
_dismissChatPreviewSnackBar();
|
||||||
|
unawaited(
|
||||||
|
_onOpenChat(
|
||||||
|
target: target,
|
||||||
|
clientName: _chatClientNameForTarget(target, senderName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: _chatSnackBarMargin(),
|
margin: _chatSnackBarMargin(),
|
||||||
duration: const Duration(seconds: 7),
|
duration: const Duration(seconds: 4),
|
||||||
content: Column(
|
content: GestureDetector(
|
||||||
mainAxisSize: MainAxisSize.min,
|
behavior: HitTestBehavior.opaque,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onTap: openChatFromPreview,
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
'$senderName (${_chatTargetLabel(target)})',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
maxLines: 1,
|
children: [
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
'$senderName (${_chatTargetLabel(target)})',
|
||||||
),
|
maxLines: 1,
|
||||||
const SizedBox(height: 4),
|
overflow: TextOverflow.ellipsis,
|
||||||
Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
action: SnackBarAction(
|
Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||||
label: 'Open',
|
],
|
||||||
onPressed: () => unawaited(
|
|
||||||
_onOpenChat(
|
|
||||||
target: target,
|
|
||||||
clientName: _chatClientNameForTarget(target, senderName),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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() {
|
EdgeInsetsGeometry _chatSnackBarMargin() {
|
||||||
@@ -1589,6 +1625,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
if (own == null) return;
|
if (own == null) return;
|
||||||
_inputMuted = own.inputMuted;
|
_inputMuted = own.inputMuted;
|
||||||
_outputMuted = own.outputMuted;
|
_outputMuted = own.outputMuted;
|
||||||
|
_hardMuteByOutputMute = own.outputMuted;
|
||||||
|
if (own.outputMuted) {
|
||||||
|
_hardMute = true;
|
||||||
|
} else if (!_inputMuted &&
|
||||||
|
!_hardMuteByPermission &&
|
||||||
|
!_hardMuteByTalkPower) {
|
||||||
|
_hardMute = false;
|
||||||
|
}
|
||||||
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
|
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
|
||||||
_currentVoiceChannelId = own.channelId;
|
_currentVoiceChannelId = own.channelId;
|
||||||
_pendingVoiceChannelId = null;
|
_pendingVoiceChannelId = null;
|
||||||
@@ -1607,7 +1651,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
rust.setInputMuted(muted: true);
|
rust.setInputMuted(muted: true);
|
||||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||||
_hardMuteByTalkPower = false;
|
_hardMuteByTalkPower = false;
|
||||||
if (!_hardMuteByPermission) {
|
if (!_hardMuteByPermission && !_hardMuteByOutputMute && !_inputMuted) {
|
||||||
_hardMute = false;
|
_hardMute = false;
|
||||||
rust.setHardMute(muted: false);
|
rust.setHardMute(muted: false);
|
||||||
rust.setInputMuted(muted: false);
|
rust.setInputMuted(muted: false);
|
||||||
@@ -1622,12 +1666,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> _onShowDiagnostics(BuildContext context) async {
|
Future<void> _onShowDiagnostics(BuildContext context) async {
|
||||||
final l10n = AppL10n.of(context);
|
final l10n = AppL10n.of(context);
|
||||||
final rustText = rust.exportDiagnostics();
|
final rustText = await rust.exportDiagnostics();
|
||||||
|
if (!context.mounted) return;
|
||||||
final uiText = _uiDiagnostics.isEmpty
|
final uiText = _uiDiagnostics.isEmpty
|
||||||
? 'UI diagnostics: none'
|
? 'UI diagnostics: none'
|
||||||
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
|
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
|
||||||
final text = '$uiText\n\nRust diagnostics:\n$rustText';
|
final text = '$uiText\n\nRust diagnostics:\n$rustText';
|
||||||
if (!mounted) return;
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class StartupDependencyCheckResult {
|
|||||||
|
|
||||||
bool get hasIssues => issues.isNotEmpty;
|
bool get hasIssues => issues.isNotEmpty;
|
||||||
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
|
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
|
||||||
|
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
|
||||||
}
|
}
|
||||||
|
|
||||||
class StartupDependencyIssue {
|
class StartupDependencyIssue {
|
||||||
|
|||||||
@@ -207,12 +207,18 @@ rust.BridgeVadBackend normalizedVadBackend(
|
|||||||
rust.BridgeVadBackend backend, {
|
rust.BridgeVadBackend backend, {
|
||||||
bool? isWindows,
|
bool? isWindows,
|
||||||
bool? isLinux,
|
bool? isLinux,
|
||||||
|
bool onnxRuntimeAvailable = true,
|
||||||
}) {
|
}) {
|
||||||
final normalized = backend == rust.BridgeVadBackend.disabled
|
final normalized = backend == rust.BridgeVadBackend.disabled
|
||||||
? rust.BridgeVadBackend.sileroOnnx
|
? rust.BridgeVadBackend.sileroOnnx
|
||||||
: backend;
|
: backend;
|
||||||
final desktop =
|
final desktop =
|
||||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||||
|
if (desktop &&
|
||||||
|
!onnxRuntimeAvailable &&
|
||||||
|
normalized == rust.BridgeVadBackend.sileroOnnx) {
|
||||||
|
return rust.BridgeVadBackend.webrtcVad;
|
||||||
|
}
|
||||||
if (!desktop) {
|
if (!desktop) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
|||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
textCapitalization: TextCapitalization.none,
|
textCapitalization: TextCapitalization.none,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => _nickFocus.requestFocus(),
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
@@ -89,6 +90,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
|||||||
focusNode: _nickFocus,
|
focusNode: _nickFocus,
|
||||||
onTapOutside: _onTapOutside,
|
onTapOutside: _onTapOutside,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -103,6 +105,7 @@ class _ConnectFormState extends State<ConnectForm> {
|
|||||||
onTapOutside: _onTapOutside,
|
onTapOutside: _onTapOutside,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => widget.onConnect(),
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
static const _channelIconColumnWidth = 24.0;
|
static const _channelIconColumnWidth = 24.0;
|
||||||
static const _channelTextGap = 8.0;
|
static const _channelTextGap = 8.0;
|
||||||
static const _userRowStartIndent = 32.0;
|
static const _userRowStartIndent = 32.0;
|
||||||
|
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||||
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
final Map<BigInt, bool> _channelExpandedById = {};
|
final Map<BigInt, bool> _channelExpandedById = {};
|
||||||
@@ -311,6 +312,7 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
final canAdjustPlayback = _canAdjustClientPlayback(client);
|
final canAdjustPlayback = _canAdjustClientPlayback(client);
|
||||||
|
Offset? secondaryTapPosition;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
@@ -320,12 +322,15 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onLongPress: canAdjustPlayback
|
onLongPress: canAdjustPlayback
|
||||||
? () =>
|
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
|
||||||
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
|
: null,
|
||||||
|
onSecondaryTapDown: canAdjustPlayback
|
||||||
|
? (details) => secondaryTapPosition = details.globalPosition
|
||||||
: null,
|
: null,
|
||||||
onSecondaryTap: canAdjustPlayback
|
onSecondaryTap: canAdjustPlayback
|
||||||
? () =>
|
? () => unawaited(
|
||||||
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
|
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 120),
|
duration: const Duration(milliseconds: 120),
|
||||||
@@ -336,14 +341,6 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
leading: status.icon,
|
leading: status.icon,
|
||||||
title: Text(client.name, style: nameStyle),
|
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();
|
const ClientPlaybackPreference();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showClientPlaybackSheet(
|
Future<void> _showClientPlaybackMenu(
|
||||||
rust.BridgeClient client, {
|
rust.BridgeClient client, {
|
||||||
|
Offset? anchor,
|
||||||
bool withHaptic = false,
|
bool withHaptic = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_canAdjustClientPlayback(client)) return;
|
if (!_canAdjustClientPlayback(client)) return;
|
||||||
@@ -415,72 +413,52 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
var preference = _clientPlaybackPreference(client);
|
final preference = _clientPlaybackPreference(client);
|
||||||
await showModalBottomSheet<void>(
|
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
|
final position = anchor ?? overlay.size.center(Offset.zero);
|
||||||
|
final selected = await showMenu<_ClientPlaybackMenuAction>(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
position: RelativeRect.fromLTRB(
|
||||||
builder: (context) {
|
position.dx,
|
||||||
return StatefulBuilder(
|
position.dy,
|
||||||
builder: (context, setModalState) {
|
overlay.size.width - position.dx,
|
||||||
void updatePreference(ClientPlaybackPreference next) {
|
overlay.size.height - position.dy,
|
||||||
setModalState(() => preference = next);
|
),
|
||||||
unawaited(onChanged(client, next));
|
items: [
|
||||||
}
|
PopupMenuItem<_ClientPlaybackMenuAction>(
|
||||||
|
value: const _ToggleMuteClientPlaybackMenuAction(),
|
||||||
final volumePercent = (preference.volume * 100).round();
|
child: ListTile(
|
||||||
return SafeArea(
|
dense: true,
|
||||||
child: Padding(
|
contentPadding: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
leading: Icon(
|
||||||
child: Column(
|
preference.muted ? Icons.volume_off : Icons.volume_up,
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
|
||||||
children: [
|
subtitle: Text(client.name),
|
||||||
Text(
|
),
|
||||||
client.name,
|
),
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
const PopupMenuDivider(),
|
||||||
),
|
..._clientPlaybackVolumePresets.map(
|
||||||
const SizedBox(height: 4),
|
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
|
||||||
Text(
|
value: _VolumeClientPlaybackMenuAction(preset),
|
||||||
'Per-user playback',
|
checked:
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
!preference.muted && (preference.volume - preset).abs() < 0.001,
|
||||||
),
|
child: Text('Volume ${(preset * 100).round()}%'),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
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(
|
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
||||||
@@ -695,6 +673,20 @@ class _ChannelTreeNode {
|
|||||||
final List<_ChannelTreeNode> children = [];
|
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) {
|
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
|
||||||
final byParent = <BigInt, List<rust.BridgeChannel>>{};
|
final byParent = <BigInt, List<rust.BridgeChannel>>{};
|
||||||
final knownIds = {for (final channel in channels) channel.id};
|
final knownIds = {for (final channel in channels) channel.id};
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ class StartupDependencyGate extends StatefulWidget {
|
|||||||
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
|
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> {
|
class _StartupDependencyGateState extends State<StartupDependencyGate> {
|
||||||
late Future<StartupDependencyCheckResult> _future;
|
late Future<StartupDependencyCheckResult> _future;
|
||||||
bool _dismissedForSession = false;
|
bool _dismissedForSession = false;
|
||||||
@@ -43,7 +64,20 @@ class _StartupDependencyGateState extends State<StartupDependencyGate> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_dismissedForSession) {
|
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>(
|
return FutureBuilder<StartupDependencyCheckResult>(
|
||||||
@@ -56,18 +90,29 @@ class _StartupDependencyGateState extends State<StartupDependencyGate> {
|
|||||||
final result = snapshot.data;
|
final result = snapshot.data;
|
||||||
if (result == null || !result.hasIssues) {
|
if (result == null || !result.hasIssues) {
|
||||||
_lastLoggedIssueSignature = null;
|
_lastLoggedIssueSignature = null;
|
||||||
return widget.child;
|
return StartupDependencyScope(
|
||||||
|
result:
|
||||||
|
result ??
|
||||||
|
const StartupDependencyCheckResult(
|
||||||
|
issues: [],
|
||||||
|
platformLabel: 'default',
|
||||||
|
),
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logIssueScreenShown(result);
|
_logIssueScreenShown(result);
|
||||||
return StartupDependencyScreen(
|
return StartupDependencyScope(
|
||||||
result: result,
|
result: result,
|
||||||
onContinue: () {
|
child: StartupDependencyScreen(
|
||||||
setState(() {
|
result: result,
|
||||||
_dismissedForSession = true;
|
onContinue: () {
|
||||||
});
|
setState(() {
|
||||||
},
|
_dismissedForSession = true;
|
||||||
onRecheck: _recheck,
|
});
|
||||||
|
},
|
||||||
|
onRecheck: _recheck,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
required String pttBoundInputClass,
|
required String pttBoundInputClass,
|
||||||
required bool isTouchOnly,
|
required bool isTouchOnly,
|
||||||
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
||||||
|
bool onnxRuntimeAvailable = true,
|
||||||
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
||||||
required ValueChanged<int> onReleaseTailChanged,
|
required ValueChanged<int> onReleaseTailChanged,
|
||||||
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
||||||
@@ -388,6 +389,7 @@ Future<void> showVoiceDetailsSheet(
|
|||||||
pttBoundInputClass: pttBoundInputClass,
|
pttBoundInputClass: pttBoundInputClass,
|
||||||
isTouchOnly: isTouchOnly,
|
isTouchOnly: isTouchOnly,
|
||||||
initialAudioConfig: initialAudioConfig,
|
initialAudioConfig: initialAudioConfig,
|
||||||
|
onnxRuntimeAvailable: onnxRuntimeAvailable,
|
||||||
onModeChanged: onModeChanged,
|
onModeChanged: onModeChanged,
|
||||||
onReleaseTailChanged: onReleaseTailChanged,
|
onReleaseTailChanged: onReleaseTailChanged,
|
||||||
onAudioConfigChanged: onAudioConfigChanged,
|
onAudioConfigChanged: onAudioConfigChanged,
|
||||||
@@ -411,6 +413,7 @@ class _VoiceSheetBody extends StatefulWidget {
|
|||||||
required this.pttBoundInputClass,
|
required this.pttBoundInputClass,
|
||||||
required this.isTouchOnly,
|
required this.isTouchOnly,
|
||||||
required this.initialAudioConfig,
|
required this.initialAudioConfig,
|
||||||
|
required this.onnxRuntimeAvailable,
|
||||||
required this.onModeChanged,
|
required this.onModeChanged,
|
||||||
required this.onReleaseTailChanged,
|
required this.onReleaseTailChanged,
|
||||||
required this.onAudioConfigChanged,
|
required this.onAudioConfigChanged,
|
||||||
@@ -428,6 +431,7 @@ class _VoiceSheetBody extends StatefulWidget {
|
|||||||
final String pttBoundInputClass;
|
final String pttBoundInputClass;
|
||||||
final bool isTouchOnly;
|
final bool isTouchOnly;
|
||||||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||||
|
final bool onnxRuntimeAvailable;
|
||||||
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
||||||
final ValueChanged<int> onReleaseTailChanged;
|
final ValueChanged<int> onReleaseTailChanged;
|
||||||
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
||||||
@@ -462,6 +466,10 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
|||||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||||
widget.initialAudioConfig,
|
widget.initialAudioConfig,
|
||||||
);
|
);
|
||||||
|
_audioProcessing.vadBackend = normalizedVadBackend(
|
||||||
|
_audioProcessing.vadBackend,
|
||||||
|
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||||
|
);
|
||||||
|
|
||||||
// Poll audio stats at 250 ms so TX/RX counters and the level meter
|
// 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.
|
// 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),
|
const SizedBox(height: 2),
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
SegmentedButton<rust.BridgeVadBackend>(
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
segments: _isDesktopSileroVadHost
|
segments: vadBackendSegmentsForAvailability(
|
||||||
? desktopVadBackendSegments
|
desktop: _isDesktopSileroVadHost,
|
||||||
: vadBackendSegments,
|
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||||
|
),
|
||||||
selected: {_audioProcessing.vadBackend},
|
selected: {_audioProcessing.vadBackend},
|
||||||
onSelectionChanged: (s) {
|
onSelectionChanged: (s) {
|
||||||
setState(() => _audioProcessing.vadBackend = s.first);
|
setState(
|
||||||
|
() => _audioProcessing.vadBackend = normalizedVadBackend(
|
||||||
|
s.first,
|
||||||
|
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||||
|
),
|
||||||
|
);
|
||||||
_notifyAudioConfig();
|
_notifyAudioConfig();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_isDesktopSileroVadHost) ...[
|
if (_isDesktopSileroVadHost) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
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(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
this.pttLevel = '',
|
this.pttLevel = '',
|
||||||
this.pttBackendId = '',
|
this.pttBackendId = '',
|
||||||
this.pttBoundInputClass = '',
|
this.pttBoundInputClass = '',
|
||||||
|
this.onnxRuntimeAvailable = true,
|
||||||
this.talkPower,
|
this.talkPower,
|
||||||
this.neededTalkPower,
|
this.neededTalkPower,
|
||||||
this.talkPowerGranted,
|
this.talkPowerGranted,
|
||||||
@@ -78,6 +79,7 @@ class VoiceSettingsDialog extends StatefulWidget {
|
|||||||
final String pttLevel;
|
final String pttLevel;
|
||||||
final String pttBackendId;
|
final String pttBackendId;
|
||||||
final String pttBoundInputClass;
|
final String pttBoundInputClass;
|
||||||
|
final bool onnxRuntimeAvailable;
|
||||||
final int? talkPower;
|
final int? talkPower;
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
final bool? talkPowerGranted;
|
final bool? talkPowerGranted;
|
||||||
@@ -101,6 +103,10 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||||
widget.initialAudioConfig,
|
widget.initialAudioConfig,
|
||||||
);
|
);
|
||||||
|
_audioProcessing.vadBackend = normalizedVadBackend(
|
||||||
|
_audioProcessing.vadBackend,
|
||||||
|
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||||
@@ -291,17 +297,24 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
|||||||
const VoiceSubHeader('Backend'),
|
const VoiceSubHeader('Backend'),
|
||||||
SegmentedButton<rust.BridgeVadBackend>(
|
SegmentedButton<rust.BridgeVadBackend>(
|
||||||
style: voiceSegmentedButtonStyle(theme),
|
style: voiceSegmentedButtonStyle(theme),
|
||||||
segments: _isDesktopSileroVadHost
|
segments: vadBackendSegmentsForAvailability(
|
||||||
? desktopVadBackendSegments
|
desktop: _isDesktopSileroVadHost,
|
||||||
: vadBackendSegments,
|
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||||
|
),
|
||||||
selected: {_audioProcessing.vadBackend},
|
selected: {_audioProcessing.vadBackend},
|
||||||
onSelectionChanged: (s) =>
|
onSelectionChanged: (s) => setState(
|
||||||
setState(() => _audioProcessing.vadBackend = s.first),
|
() => _audioProcessing.vadBackend = normalizedVadBackend(
|
||||||
|
s.first,
|
||||||
|
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_isDesktopSileroVadHost)
|
if (_isDesktopSileroVadHost)
|
||||||
Text(
|
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(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ const vadBackendSegments = [
|
|||||||
/// smaller dependency surface.
|
/// smaller dependency surface.
|
||||||
const desktopVadBackendSegments = vadBackendSegments;
|
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.
|
/// Section subheader used by both voice settings surfaces.
|
||||||
class VoiceSubHeader extends StatelessWidget {
|
class VoiceSubHeader extends StatelessWidget {
|
||||||
/// Construct a voice settings subheader.
|
/// 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', () {
|
test('default config matches the current platform fallback', () {
|
||||||
final fallback = defaultAudioProcessingConfig();
|
final fallback = defaultAudioProcessingConfig();
|
||||||
final desktop = Platform.isWindows || Platform.isLinux;
|
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,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -288,20 +288,12 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tap(find.byIcon(Icons.more_horiz));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Per-user playback'), findsOneWidget);
|
expect(find.byIcon(Icons.more_horiz), findsNothing);
|
||||||
expect(find.text('Mute playback'), findsOneWidget);
|
|
||||||
expect(find.text('Volume 50%'), findsOneWidget);
|
|
||||||
expect(
|
|
||||||
find.byKey(const Key('client-playback-volume-slider')),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('remote client long press opens playback sheet', (tester) async {
|
testWidgets('remote client long press opens playback menu', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
snapshotHarness(
|
snapshotHarness(
|
||||||
channels: [channel(id: 1, name: 'Default Channel')],
|
channels: [channel(id: 1, name: 'Default Channel')],
|
||||||
@@ -318,11 +310,11 @@ void main() {
|
|||||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Per-user playback'), findsOneWidget);
|
|
||||||
expect(find.text('Mute 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,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -344,11 +336,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Per-user playback'), findsOneWidget);
|
|
||||||
expect(find.text('Mute 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,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final changes = <ClientPlaybackPreference>[];
|
final changes = <ClientPlaybackPreference>[];
|
||||||
@@ -368,7 +360,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byIcon(Icons.more_horiz));
|
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Mute playback'));
|
await tester.tap(find.text('Mute playback'));
|
||||||
@@ -377,13 +369,13 @@ void main() {
|
|||||||
expect(changes, isNotEmpty);
|
expect(changes, isNotEmpty);
|
||||||
expect(changes.last.muted, isTrue);
|
expect(changes.last.muted, isTrue);
|
||||||
|
|
||||||
await tester.drag(
|
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||||
find.byKey(const Key('client-playback-volume-slider')),
|
await tester.pumpAndSettle();
|
||||||
const Offset(160, 0),
|
await tester.tap(find.text('Volume 200%'), warnIfMissed: false);
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
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', (
|
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', (
|
testWidgets('shared segmented style applies compact visual density', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user