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
@@ -1108,6 +1108,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
case 4:
return BridgeError_AlreadyConnected();
case 5:
return BridgeError_ServerRejected(
code: dco_decode_u_32(raw[1]),
message: dco_decode_String(raw[2]),
);
case 6:
return BridgeError_Unmapped(dco_decode_String(raw[1]));
default:
throw Exception("unreachable");
@@ -1392,6 +1397,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
case 4:
return BridgeError_AlreadyConnected();
case 5:
var var_code = sse_decode_u_32(deserializer);
var var_message = sse_decode_String(deserializer);
return BridgeError_ServerRejected(code: var_code, message: var_message);
case 6:
var var_field0 = sse_decode_String(deserializer);
return BridgeError_Unmapped(var_field0);
default:
@@ -1722,8 +1731,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(3, serializer);
case BridgeError_AlreadyConnected():
sse_encode_i_32(4, serializer);
case BridgeError_Unmapped(field0: final field0):
case BridgeError_ServerRejected(code: final code, message: final message):
sse_encode_i_32(5, serializer);
sse_encode_u_32(code, serializer);
sse_encode_String(message, serializer);
case BridgeError_Unmapped(field0: final field0):
sse_encode_i_32(6, serializer);
sse_encode_String(field0, serializer);
}
}
@@ -36,6 +36,19 @@ sealed class BridgeError with _$BridgeError implements FrbException {
/// connection.
const factory BridgeError.alreadyConnected() = BridgeError_AlreadyConnected;
/// A server command was rejected by the TeamSpeak server. The
/// `code` is the canonical TS3 error number (see
/// https://github.com/ReSpeak/tsdeclarations Errors.csv); the
/// `message` is the server-supplied text. The UI uses `code`
/// to look up a localised explanation.
const factory BridgeError.serverRejected({
/// Raw TS3 error code.
required int code,
/// Server-supplied message text.
required String message,
}) = BridgeError_ServerRejected;
/// An unmapped error escaped the subsystem boundary. Production
/// callers should never see this; if they do, it is a mapping
/// bug here.
@@ -55,7 +55,7 @@ extension BridgeErrorPatterns on BridgeError {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeError_InvalidCommand value)? invalidCommand,TResult Function( BridgeError_DnsFailed value)? dnsFailed,TResult Function( BridgeError_Connection value)? connection,TResult Function( BridgeError_NotConnected value)? notConnected,TResult Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeError_InvalidCommand value)? invalidCommand,TResult Function( BridgeError_DnsFailed value)? dnsFailed,TResult Function( BridgeError_Connection value)? connection,TResult Function( BridgeError_NotConnected value)? notConnected,TResult Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult Function( BridgeError_ServerRejected value)? serverRejected,TResult Function( BridgeError_Unmapped value)? unmapped,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
@@ -63,7 +63,8 @@ return invalidCommand(_that);case BridgeError_DnsFailed() when dnsFailed != null
return dnsFailed(_that);case BridgeError_Connection() when connection != null:
return connection(_that);case BridgeError_NotConnected() when notConnected != null:
return notConnected(_that);case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected(_that);case BridgeError_Unmapped() when unmapped != null:
return alreadyConnected(_that);case BridgeError_ServerRejected() when serverRejected != null:
return serverRejected(_that);case BridgeError_Unmapped() when unmapped != null:
return unmapped(_that);case _:
return orElse();
@@ -82,7 +83,7 @@ return unmapped(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeError_InvalidCommand value) invalidCommand,required TResult Function( BridgeError_DnsFailed value) dnsFailed,required TResult Function( BridgeError_Connection value) connection,required TResult Function( BridgeError_NotConnected value) notConnected,required TResult Function( BridgeError_AlreadyConnected value) alreadyConnected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeError_InvalidCommand value) invalidCommand,required TResult Function( BridgeError_DnsFailed value) dnsFailed,required TResult Function( BridgeError_Connection value) connection,required TResult Function( BridgeError_NotConnected value) notConnected,required TResult Function( BridgeError_AlreadyConnected value) alreadyConnected,required TResult Function( BridgeError_ServerRejected value) serverRejected,required TResult Function( BridgeError_Unmapped value) unmapped,}){
final _that = this;
switch (_that) {
case BridgeError_InvalidCommand():
@@ -90,7 +91,8 @@ return invalidCommand(_that);case BridgeError_DnsFailed():
return dnsFailed(_that);case BridgeError_Connection():
return connection(_that);case BridgeError_NotConnected():
return notConnected(_that);case BridgeError_AlreadyConnected():
return alreadyConnected(_that);case BridgeError_Unmapped():
return alreadyConnected(_that);case BridgeError_ServerRejected():
return serverRejected(_that);case BridgeError_Unmapped():
return unmapped(_that);}
}
/// A variant of `map` that fallback to returning `null`.
@@ -105,7 +107,7 @@ return unmapped(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeError_InvalidCommand value)? invalidCommand,TResult? Function( BridgeError_DnsFailed value)? dnsFailed,TResult? Function( BridgeError_Connection value)? connection,TResult? Function( BridgeError_NotConnected value)? notConnected,TResult? Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeError_InvalidCommand value)? invalidCommand,TResult? Function( BridgeError_DnsFailed value)? dnsFailed,TResult? Function( BridgeError_Connection value)? connection,TResult? Function( BridgeError_NotConnected value)? notConnected,TResult? Function( BridgeError_AlreadyConnected value)? alreadyConnected,TResult? Function( BridgeError_ServerRejected value)? serverRejected,TResult? Function( BridgeError_Unmapped value)? unmapped,}){
final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
@@ -113,7 +115,8 @@ return invalidCommand(_that);case BridgeError_DnsFailed() when dnsFailed != null
return dnsFailed(_that);case BridgeError_Connection() when connection != null:
return connection(_that);case BridgeError_NotConnected() when notConnected != null:
return notConnected(_that);case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected(_that);case BridgeError_Unmapped() when unmapped != null:
return alreadyConnected(_that);case BridgeError_ServerRejected() when serverRejected != null:
return serverRejected(_that);case BridgeError_Unmapped() when unmapped != null:
return unmapped(_that);case _:
return null;
@@ -131,14 +134,15 @@ return unmapped(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String field0)? invalidCommand,TResult Function( String host, String reason)? dnsFailed,TResult Function( String field0)? connection,TResult Function()? notConnected,TResult Function()? alreadyConnected,TResult Function( String field0)? unmapped,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String field0)? invalidCommand,TResult Function( String host, String reason)? dnsFailed,TResult Function( String field0)? connection,TResult Function()? notConnected,TResult Function()? alreadyConnected,TResult Function( int code, String message)? serverRejected,TResult Function( String field0)? unmapped,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
return invalidCommand(_that.field0);case BridgeError_DnsFailed() when dnsFailed != null:
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection() when connection != null:
return connection(_that.field0);case BridgeError_NotConnected() when notConnected != null:
return notConnected();case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected();case BridgeError_Unmapped() when unmapped != null:
return alreadyConnected();case BridgeError_ServerRejected() when serverRejected != null:
return serverRejected(_that.code,_that.message);case BridgeError_Unmapped() when unmapped != null:
return unmapped(_that.field0);case _:
return orElse();
@@ -157,14 +161,15 @@ return unmapped(_that.field0);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String field0) invalidCommand,required TResult Function( String host, String reason) dnsFailed,required TResult Function( String field0) connection,required TResult Function() notConnected,required TResult Function() alreadyConnected,required TResult Function( String field0) unmapped,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String field0) invalidCommand,required TResult Function( String host, String reason) dnsFailed,required TResult Function( String field0) connection,required TResult Function() notConnected,required TResult Function() alreadyConnected,required TResult Function( int code, String message) serverRejected,required TResult Function( String field0) unmapped,}) {final _that = this;
switch (_that) {
case BridgeError_InvalidCommand():
return invalidCommand(_that.field0);case BridgeError_DnsFailed():
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection():
return connection(_that.field0);case BridgeError_NotConnected():
return notConnected();case BridgeError_AlreadyConnected():
return alreadyConnected();case BridgeError_Unmapped():
return alreadyConnected();case BridgeError_ServerRejected():
return serverRejected(_that.code,_that.message);case BridgeError_Unmapped():
return unmapped(_that.field0);}
}
/// A variant of `when` that fallback to returning `null`
@@ -179,14 +184,15 @@ return unmapped(_that.field0);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String field0)? invalidCommand,TResult? Function( String host, String reason)? dnsFailed,TResult? Function( String field0)? connection,TResult? Function()? notConnected,TResult? Function()? alreadyConnected,TResult? Function( String field0)? unmapped,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String field0)? invalidCommand,TResult? Function( String host, String reason)? dnsFailed,TResult? Function( String field0)? connection,TResult? Function()? notConnected,TResult? Function()? alreadyConnected,TResult? Function( int code, String message)? serverRejected,TResult? Function( String field0)? unmapped,}) {final _that = this;
switch (_that) {
case BridgeError_InvalidCommand() when invalidCommand != null:
return invalidCommand(_that.field0);case BridgeError_DnsFailed() when dnsFailed != null:
return dnsFailed(_that.host,_that.reason);case BridgeError_Connection() when connection != null:
return connection(_that.field0);case BridgeError_NotConnected() when notConnected != null:
return notConnected();case BridgeError_AlreadyConnected() when alreadyConnected != null:
return alreadyConnected();case BridgeError_Unmapped() when unmapped != null:
return alreadyConnected();case BridgeError_ServerRejected() when serverRejected != null:
return serverRejected(_that.code,_that.message);case BridgeError_Unmapped() when unmapped != null:
return unmapped(_that.field0);case _:
return null;
@@ -461,6 +467,76 @@ String toString() {
/// @nodoc
class BridgeError_ServerRejected extends BridgeError {
const BridgeError_ServerRejected({required this.code, required this.message}): super._();
/// Raw TS3 error code.
final int code;
/// Server-supplied message text.
final String message;
/// Create a copy of BridgeError
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeError_ServerRejectedCopyWith<BridgeError_ServerRejected> get copyWith => _$BridgeError_ServerRejectedCopyWithImpl<BridgeError_ServerRejected>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeError_ServerRejected&&(identical(other.code, code) || other.code == code)&&(identical(other.message, message) || other.message == message));
}
@override
int get hashCode => Object.hash(runtimeType,code,message);
@override
String toString() {
return 'BridgeError.serverRejected(code: $code, message: $message)';
}
}
/// @nodoc
abstract mixin class $BridgeError_ServerRejectedCopyWith<$Res> implements $BridgeErrorCopyWith<$Res> {
factory $BridgeError_ServerRejectedCopyWith(BridgeError_ServerRejected value, $Res Function(BridgeError_ServerRejected) _then) = _$BridgeError_ServerRejectedCopyWithImpl;
@useResult
$Res call({
int code, String message
});
}
/// @nodoc
class _$BridgeError_ServerRejectedCopyWithImpl<$Res>
implements $BridgeError_ServerRejectedCopyWith<$Res> {
_$BridgeError_ServerRejectedCopyWithImpl(this._self, this._then);
final BridgeError_ServerRejected _self;
final $Res Function(BridgeError_ServerRejected) _then;
/// Create a copy of BridgeError
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? code = null,Object? message = null,}) {
return _then(BridgeError_ServerRejected(
code: null == code ? _self.code : code // ignore: cast_nullable_to_non_nullable
as int,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc