fix: address post-event-driven issues and client info parity (#16)
* fix(ui): restore speaking status indicators Speaking state (isSpeaking) is computed from voice activity timestamps in the protocol layer and cannot be represented as a discrete delta. The event-driven refactor removed periodic snapshot refreshes, causing speaking indicators to go stale. Adds a 750ms periodic snapshot refresh (matching SPEAKING_ACTIVITY_WINDOW) while the audio stats timer is active (in-channel only). Structural changes (moves, joins, leaves) are still handled by instant deltas. * fix(ui): hide server query clients from delta joins When a ServerQuery client sends a message, a ClientJoined delta fires. Before PR#15 the periodic snapshot rebuild would include the SQ client but the snapshot_view filter hid it. With deltas, the client persisted in the local snapshot. Now ClientJoined deltas skip SQ clients entirely. * fix(proto): log getconnectioninfo errors instead of silently discarding Ping and packet loss showing 'Unknown' in the client info sheet is caused by getconnectioninfo failures being silently swallowed. Now logs the error with the client_id so the root cause can be diagnosed (e.g. missing b_client_connectioninfo_view permission on the server). Also logs clientgetvariables failures. * fix(proto): refresh non-self client profiles before mapping * feat(protocol): add ping deviation to client profiles * chore(ui): regenerate Flutter bridge bindings for ping deviation * fix(l10n): add ping deviation labels to client info * feat(ui): show ping deviation in client info sheet
This commit is contained in:
@@ -227,6 +227,7 @@
|
||||
"clientInfoOnline": "Online",
|
||||
"clientInfoIdle": "Idle",
|
||||
"clientInfoPing": "Ping",
|
||||
"clientInfoPingDeviation": "Ping deviation",
|
||||
"clientInfoAddress": "Address",
|
||||
"clientInfoPacketLossClientToServer": "Packet loss C->S",
|
||||
"clientInfoPacketLossServerToClient": "Packet loss S->C",
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
"clientInfoOnline": "在线时长",
|
||||
"clientInfoIdle": "空闲",
|
||||
"clientInfoPing": "延迟",
|
||||
"clientInfoPingDeviation": "延迟偏差",
|
||||
"clientInfoAddress": "地址",
|
||||
"clientInfoPacketLossClientToServer": "丢包 C->S",
|
||||
"clientInfoPacketLossServerToClient": "丢包 S->C",
|
||||
|
||||
@@ -1069,6 +1069,12 @@ abstract class AppL10n {
|
||||
/// **'Ping'**
|
||||
String get clientInfoPing;
|
||||
|
||||
/// No description provided for @clientInfoPingDeviation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ping deviation'**
|
||||
String get clientInfoPingDeviation;
|
||||
|
||||
/// No description provided for @clientInfoAddress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -543,6 +543,9 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get clientInfoPing => 'Ping';
|
||||
|
||||
@override
|
||||
String get clientInfoPingDeviation => 'Ping deviation';
|
||||
|
||||
@override
|
||||
String get clientInfoAddress => 'Address';
|
||||
|
||||
|
||||
@@ -531,6 +531,9 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get clientInfoPing => '延迟';
|
||||
|
||||
@override
|
||||
String get clientInfoPingDeviation => '延迟偏差';
|
||||
|
||||
@override
|
||||
String get clientInfoAddress => '地址';
|
||||
|
||||
|
||||
@@ -809,17 +809,19 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
return updated;
|
||||
});
|
||||
case rust.BridgeEvent_ClientJoined(:final clientId, :final channelId, :final name, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted):
|
||||
_applyClientAdd(rust.BridgeClient(
|
||||
id: clientId,
|
||||
channel: channelId,
|
||||
name: name,
|
||||
inputMuted: inputMuted,
|
||||
outputMuted: outputMuted,
|
||||
isSpeaking: false,
|
||||
isServerQuery: isServerQuery,
|
||||
talkPower: talkPower,
|
||||
talkPowerGranted: talkPowerGranted,
|
||||
));
|
||||
if (!isServerQuery) {
|
||||
_applyClientAdd(rust.BridgeClient(
|
||||
id: clientId,
|
||||
channel: channelId,
|
||||
name: name,
|
||||
inputMuted: inputMuted,
|
||||
outputMuted: outputMuted,
|
||||
isSpeaking: false,
|
||||
isServerQuery: isServerQuery,
|
||||
talkPower: talkPower,
|
||||
talkPowerGranted: talkPowerGranted,
|
||||
));
|
||||
}
|
||||
case rust.BridgeEvent_ClientLeft(:final clientId):
|
||||
_applyClientRemove(clientId);
|
||||
case rust.BridgeEvent_ClientUpdated(:final clientId, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted):
|
||||
@@ -871,14 +873,25 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (_serverReachable) _ensureStatsTimer();
|
||||
}
|
||||
|
||||
DateTime? _lastSpeakingRefresh;
|
||||
static const _speakingRefreshInterval = Duration(milliseconds: 750);
|
||||
|
||||
void _ensureStatsTimer() {
|
||||
if (_statsTimer != null) return;
|
||||
_lastSpeakingRefresh = null;
|
||||
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
||||
try {
|
||||
final s = await rust.audioStats();
|
||||
if (!mounted) return;
|
||||
setState(() => _audioStats = s);
|
||||
} catch (_) {}
|
||||
final now = DateTime.now();
|
||||
if (_inChannel &&
|
||||
(_lastSpeakingRefresh == null ||
|
||||
now.difference(_lastSpeakingRefresh!) >= _speakingRefreshInterval)) {
|
||||
_lastSpeakingRefresh = now;
|
||||
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -917,6 +917,9 @@ class BridgeClientProfile {
|
||||
/// Current ping in milliseconds.
|
||||
final PlatformInt64? pingMilliseconds;
|
||||
|
||||
/// Current ping deviation in milliseconds.
|
||||
final PlatformInt64? pingDeviationMilliseconds;
|
||||
|
||||
/// Client address. Empty when permission-gated.
|
||||
final String clientAddress;
|
||||
|
||||
@@ -963,6 +966,7 @@ class BridgeClientProfile {
|
||||
this.onlineSeconds,
|
||||
this.idleMilliseconds,
|
||||
this.pingMilliseconds,
|
||||
this.pingDeviationMilliseconds,
|
||||
required this.clientAddress,
|
||||
required this.serverGroups,
|
||||
required this.channelGroup,
|
||||
@@ -992,6 +996,7 @@ class BridgeClientProfile {
|
||||
onlineSeconds.hashCode ^
|
||||
idleMilliseconds.hashCode ^
|
||||
pingMilliseconds.hashCode ^
|
||||
pingDeviationMilliseconds.hashCode ^
|
||||
clientAddress.hashCode ^
|
||||
serverGroups.hashCode ^
|
||||
channelGroup.hashCode ^
|
||||
@@ -1023,6 +1028,7 @@ class BridgeClientProfile {
|
||||
onlineSeconds == other.onlineSeconds &&
|
||||
idleMilliseconds == other.idleMilliseconds &&
|
||||
pingMilliseconds == other.pingMilliseconds &&
|
||||
pingDeviationMilliseconds == other.pingDeviationMilliseconds &&
|
||||
clientAddress == other.clientAddress &&
|
||||
serverGroups == other.serverGroups &&
|
||||
channelGroup == other.channelGroup &&
|
||||
|
||||
@@ -1844,8 +1844,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 25)
|
||||
throw Exception('unexpected arr length: expect 25 but see ${arr.length}');
|
||||
if (arr.length != 26)
|
||||
throw Exception('unexpected arr length: expect 26 but see ${arr.length}');
|
||||
return BridgeClientProfile(
|
||||
id: dco_decode_u_64(arr[0]),
|
||||
channel: dco_decode_u_64(arr[1]),
|
||||
@@ -1862,16 +1862,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
onlineSeconds: dco_decode_opt_box_autoadd_i_64(arr[12]),
|
||||
idleMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[13]),
|
||||
pingMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[14]),
|
||||
clientAddress: dco_decode_String(arr[15]),
|
||||
serverGroups: dco_decode_list_String(arr[16]),
|
||||
channelGroup: dco_decode_String(arr[17]),
|
||||
avatarPath: dco_decode_String(arr[18]),
|
||||
bytesDownloadedMonth: dco_decode_opt_box_autoadd_u_64(arr[19]),
|
||||
bytesUploadedMonth: dco_decode_opt_box_autoadd_u_64(arr[20]),
|
||||
bytesDownloadedTotal: dco_decode_opt_box_autoadd_u_64(arr[21]),
|
||||
bytesUploadedTotal: dco_decode_opt_box_autoadd_u_64(arr[22]),
|
||||
packetLossClientToServerTotal: dco_decode_opt_box_autoadd_f_32(arr[23]),
|
||||
packetLossServerToClientTotal: dco_decode_opt_box_autoadd_f_32(arr[24]),
|
||||
pingDeviationMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[15]),
|
||||
clientAddress: dco_decode_String(arr[16]),
|
||||
serverGroups: dco_decode_list_String(arr[17]),
|
||||
channelGroup: dco_decode_String(arr[18]),
|
||||
avatarPath: dco_decode_String(arr[19]),
|
||||
bytesDownloadedMonth: dco_decode_opt_box_autoadd_u_64(arr[20]),
|
||||
bytesUploadedMonth: dco_decode_opt_box_autoadd_u_64(arr[21]),
|
||||
bytesDownloadedTotal: dco_decode_opt_box_autoadd_u_64(arr[22]),
|
||||
bytesUploadedTotal: dco_decode_opt_box_autoadd_u_64(arr[23]),
|
||||
packetLossClientToServerTotal: dco_decode_opt_box_autoadd_f_32(arr[24]),
|
||||
packetLossServerToClientTotal: dco_decode_opt_box_autoadd_f_32(arr[25]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2577,6 +2578,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_onlineSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_idleMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_pingMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_pingDeviationMilliseconds = sse_decode_opt_box_autoadd_i_64(
|
||||
deserializer,
|
||||
);
|
||||
var var_clientAddress = sse_decode_String(deserializer);
|
||||
var var_serverGroups = sse_decode_list_String(deserializer);
|
||||
var var_channelGroup = sse_decode_String(deserializer);
|
||||
@@ -2611,6 +2615,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
onlineSeconds: var_onlineSeconds,
|
||||
idleMilliseconds: var_idleMilliseconds,
|
||||
pingMilliseconds: var_pingMilliseconds,
|
||||
pingDeviationMilliseconds: var_pingDeviationMilliseconds,
|
||||
clientAddress: var_clientAddress,
|
||||
serverGroups: var_serverGroups,
|
||||
channelGroup: var_channelGroup,
|
||||
@@ -3436,6 +3441,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_opt_box_autoadd_i_64(self.onlineSeconds, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.idleMilliseconds, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.pingMilliseconds, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.pingDeviationMilliseconds, serializer);
|
||||
sse_encode_String(self.clientAddress, serializer);
|
||||
sse_encode_list_String(self.serverGroups, serializer);
|
||||
sse_encode_String(self.channelGroup, serializer);
|
||||
|
||||
@@ -214,6 +214,11 @@ class _ClientInfoContent extends StatelessWidget {
|
||||
l10n.clientInfoPing,
|
||||
_formatMilliseconds(profile.pingMilliseconds, l10n),
|
||||
),
|
||||
if (profile.pingDeviationMilliseconds != null)
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPingDeviation,
|
||||
_formatMilliseconds(profile.pingDeviationMilliseconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoAddress,
|
||||
_emptyAsHidden(profile.clientAddress, l10n),
|
||||
|
||||
@@ -20,6 +20,7 @@ void main() {
|
||||
onlineSeconds: 3661,
|
||||
idleMilliseconds: 42000,
|
||||
pingMilliseconds: 38,
|
||||
pingDeviationMilliseconds: 7,
|
||||
clientAddress: '203.0.113.24',
|
||||
serverGroups: const ['Admin', 'Talk Power'],
|
||||
channelGroup: 'Guest',
|
||||
@@ -77,9 +78,56 @@ void main() {
|
||||
expect(find.text('Connection'), findsOneWidget);
|
||||
expect(find.text('1h 1m 1s'), findsOneWidget);
|
||||
expect(find.text('42.00 s'), findsOneWidget);
|
||||
expect(find.text('7 ms'), findsOneWidget);
|
||||
expect(find.text('203.0.113.24'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides ping deviation row when the value is absent', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 500,
|
||||
child: ClientInfoSheet(
|
||||
clientName: 'Bob',
|
||||
loadProfile: () async => rust.BridgeClientProfile(
|
||||
id: BigInt.from(101),
|
||||
channel: BigInt.from(7),
|
||||
name: 'Bob',
|
||||
uniqueId: 'client-unique-id',
|
||||
databaseId: BigInt.from(55),
|
||||
countryCode: 'US',
|
||||
description: 'Operator',
|
||||
version: '3.6.2',
|
||||
platform: 'Windows',
|
||||
onlineSeconds: 3661,
|
||||
idleMilliseconds: 42000,
|
||||
pingMilliseconds: 38,
|
||||
clientAddress: '203.0.113.24',
|
||||
serverGroups: const ['Admin'],
|
||||
channelGroup: 'Guest',
|
||||
avatarPath: '/avatar_aabbcc',
|
||||
bytesDownloadedMonth: BigInt.from(2048),
|
||||
bytesUploadedMonth: BigInt.from(4096),
|
||||
bytesDownloadedTotal: BigInt.from(1048576),
|
||||
bytesUploadedTotal: BigInt.from(2097152),
|
||||
packetLossClientToServerTotal: 0.0123,
|
||||
packetLossServerToClientTotal: 0.0456,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Ping deviation'), findsNothing);
|
||||
expect(find.text('7 ms'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('loading and error states use localized copy', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
Reference in New Issue
Block a user