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:
EdisonJwa
2026-05-16 03:27:25 +08:00
parent 0b6ea11077
commit 2511b24982
15 changed files with 644 additions and 112 deletions
+152 -32
View File
@@ -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