fix(audio,protocol,ui): TC-2.3 + TC-10 + TC-13 + channel tree hierarchy
Five user-reported defects + one auto-test regression-catcher.
== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.
Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.
Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.
New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.
== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.
Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.
Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
carries the canonical TS3 error code per the official catalogue
at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
The connection-task loop holds a pending_moves HashMap keyed by
MessageHandle, services StreamItem::MessageResult by looking up
and resolving the reply with either Ok or the typed
ServerRejected.
* Pending entries have a 3 s deadline so a server that never
replies doesn't leak the reply channel — expired entries fall
back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
full snapshot poll), still polls for confirmation as a
belt-and-braces fallback for legacy servers; on poll failure
emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
localised message selected by error code (channelJoinFailed*
l10n entries). 6 known codes mapped to specific messages
(insufficient permission, wrong password, channel full,
family limit, private channel, timeout); everything else
falls back to the server-supplied generic message.
== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.
Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.
== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.
Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).
== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.
Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
+2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
This commit is contained in:
@@ -18,6 +18,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'l10n/generated/app_localizations.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_settings.dart';
|
||||
@@ -354,6 +355,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
|
||||
final l10n = AppL10n.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
String? password;
|
||||
// Heuristic: a channel name annotated with a lock prompts.
|
||||
if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) {
|
||||
@@ -369,10 +371,62 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
setState(() => _currentVoiceChannelId = ch.id);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
// Surface as a SnackBar so the user sees it even while
|
||||
// connected (the persistent _error string lives in the
|
||||
// pre-connect area and is hidden post-connect). The message
|
||||
// is selected by TS3 error code per the canonical
|
||||
// catalogue at https://github.com/ReSpeak/tsdeclarations.
|
||||
final message = _channelJoinErrorMessage(l10n, e);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `voiceJoin` error to a localised user-facing message.
|
||||
/// Recognises the typed `BridgeError.serverRejected` variant
|
||||
/// (with a TS3 error code) and falls back to a generic message
|
||||
/// for everything else.
|
||||
String _channelJoinErrorMessage(AppL10n l10n, Object e) {
|
||||
if (e is rust_err.BridgeError) {
|
||||
return e.when(
|
||||
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
dnsFailed: (host, reason) =>
|
||||
l10n.channelJoinFailedGeneric('$host: $reason'),
|
||||
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
||||
alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'),
|
||||
serverRejected: (code, message) {
|
||||
// Canonical TS3 error codes per ReSpeak/tsdeclarations
|
||||
// Errors.csv.
|
||||
switch (code) {
|
||||
case 0x0001:
|
||||
// Our sentinel for "snapshot poll didn't confirm".
|
||||
return l10n.channelJoinFailedTimeout;
|
||||
case 0x0a08: // permissions_client_insufficient
|
||||
return l10n.channelJoinFailedPermission;
|
||||
case 0x030d: // channel_invalid_password
|
||||
return l10n.channelJoinFailedPassword;
|
||||
case 0x0309: // channel_maxclients_reached
|
||||
return l10n.channelJoinFailedFull;
|
||||
case 0x030a: // channel_maxfamily_reached
|
||||
return l10n.channelJoinFailedFamilyFull;
|
||||
case 0x030e: // channel_is_private_channel
|
||||
return l10n.channelJoinFailedPrivate;
|
||||
default:
|
||||
return l10n.channelJoinFailedGeneric(message);
|
||||
}
|
||||
},
|
||||
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
);
|
||||
}
|
||||
return l10n.channelJoinFailedGeneric(e.toString());
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
Future<void> _onLeaveVoice() async {
|
||||
try {
|
||||
@@ -853,27 +907,57 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
||||
VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
);
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
);
|
||||
// Responsive: at <840 dp use a stacked layout
|
||||
// (Voice Bar on top, channel tree below). At
|
||||
// ≥840 dp use a side-by-side layout with the
|
||||
// Voice Bar pinned to 320 dp on the left and
|
||||
// the channel tree expanding on the right.
|
||||
// 840 dp matches Material's tablet / desktop
|
||||
// breakpoint.
|
||||
const wideBreakpoint = 840.0;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= wideBreakpoint) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: voiceBarWidthWide, child: voiceBar),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
voiceBar,
|
||||
const SizedBox(height: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1183,6 +1267,24 @@ class _SnapshotView extends StatelessWidget {
|
||||
// in that adapter (TS3 `order` is NOT a numeric rank).
|
||||
final channels = snapshot.channels;
|
||||
|
||||
// Compute each channel's depth in the parent hierarchy so we
|
||||
// can render it indented. Root channels (parent == 0) are
|
||||
// depth 0; their children depth 1; and so on. Depth caps at
|
||||
// 6 to keep the indent visually bounded on deeply-nested
|
||||
// servers (the cap plateaus silently — no glyph; the channel
|
||||
// is still tappable and its real depth lives in the data).
|
||||
final depthById = <BigInt, int>{};
|
||||
final zero = BigInt.zero;
|
||||
for (final ch in channels) {
|
||||
if (ch.parent == zero) {
|
||||
depthById[ch.id] = 0;
|
||||
} else {
|
||||
final parentDepth = depthById[ch.parent] ?? 0;
|
||||
depthById[ch.id] = (parentDepth + 1).clamp(0, 6);
|
||||
}
|
||||
}
|
||||
const indentPerLevel = 18.0;
|
||||
|
||||
final byChannel = <BigInt, List<rust.BridgeClient>>{};
|
||||
for (final c in snapshot.clients) {
|
||||
byChannel.putIfAbsent(c.channel, () => []).add(c);
|
||||
@@ -1212,21 +1314,28 @@ class _SnapshotView extends StatelessWidget {
|
||||
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
for (final ch in channels) ...[
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.tag),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.login),
|
||||
tooltip: l10n.joinChannelAction,
|
||||
onPressed: () => onJoinChannel(ch),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: (depthById[ch.id] ?? 0) * indentPerLevel,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.tag),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.login),
|
||||
tooltip: l10n.joinChannelAction,
|
||||
onPressed: () => onJoinChannel(ch),
|
||||
),
|
||||
onTap: () => onJoinChannel(ch),
|
||||
),
|
||||
onTap: () => onJoinChannel(ch),
|
||||
),
|
||||
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 64),
|
||||
padding: EdgeInsets.only(
|
||||
left: ((depthById[ch.id] ?? 0) * indentPerLevel) + 64,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
@@ -1371,6 +1480,17 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
|
||||
onKeyEvent: _onKeyEvent,
|
||||
autofocus: true,
|
||||
child: Listener(
|
||||
// HitTestBehavior.opaque so the Listener fires for
|
||||
// mouse side-button presses anywhere inside the
|
||||
// dialog's bounds, not just on top of a child widget.
|
||||
// Default (deferToChild) only delivers events when a
|
||||
// child's hit-test claims them; the surrounding padding
|
||||
// and Container backgrounds don't, so the user had to
|
||||
// hover over the captured-result Container before the
|
||||
// side-button click would register. Opaque means the
|
||||
// entire dialog content area receives PointerDown
|
||||
// events.
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// Capture mouse side buttons (4 and 5) without
|
||||
// capturing primary / secondary clicks which the
|
||||
// user uses to interact with the dialog itself. The
|
||||
|
||||
Reference in New Issue
Block a user