feat: event-driven UI updates for instant channel switching (#15)
* chore: regenerate Cargo.lock after rebase * fix(ui): add 1s cool-down to prevent double-tap channel join voiceJoin returns instantly (fire-and-forget protocol), so the pending-join guard clears before a second tap lands. The cool-down prevents the rapid channel oscillation and ClientIsFlooding (524) that results from double-tapping. * fix(ui): handle ChannelAlreadyIn as success, ClientIsFlooding with backoff - ChannelAlreadyIn (0x0302): treat as silent success, update UI state - ClientIsFlooding (0x020c): show localized snackbar, extend cooldown 5s - Add l10n strings for flooding error (en + zh) * fix(proto): use Windows TS3 client version for broadest compatibility Matches Qint's default (Windows_3_X_X__1). Avoids server-side behavioral differences with TS5 version strings. * fix(proto): patch tsproto-types to handle short P-256 coordinates BigInt::to_bytes_be() strips leading zeros, causing WrongPublicKeyLength when a server's ephemeral key coordinate starts with 0x00. Patch from EdisonJwa/tsclientlib fix/p256-short-coordinate-pad branch left-pads coordinates to the P-256 field size instead of rejecting them. * refactor(core): stop watchdog from emitting SnapshotChanged The watchdog now serves only as a liveness probe (miss counting for reconnection). UI updates are handled entirely by the event-driven delta path (ProtocolDelta → SessionEvent → BridgeEvent → Flutter). Removes signature tracking and SnapshotChanged emission from the supervisor loop. The initial snapshot is still fetched via the Connected event handler in Flutter. * refactor(ui): remove channel-join cooldown guard With event-driven deltas the UI updates instantly on channel moves, so the 1-second cooldown is no longer needed. Double-taps are handled by the server (ChannelAlreadyIn → success) and the pending-channel-id guard prevents overlapping requests. Also removes the _lastJoinCompletedAt field entirely. * fix(core): reattach event forwarders after reconnect The reconnect path swapped in a new ProtocolClient but never took chat_rx, activity_rx, or delta_rx from it. After the first reconnect, the event-driven UI pipeline was dead. Fix by extracting spawn_event_forwarders() helper called on both initial connect and reconnect. Also replaces lossy try_recv+sleep polling with proper recv().await for push-based delivery. * feat(protocol): enrich delta schema with all snapshot-visible fields ClientJoined now carries input_muted, output_muted, is_server_query, talk_power, talk_power_granted. ChannelAdded/ChannelUpdated now carry has_password and needed_talk_power. ClientUpdated also carries is_server_query, talk_power, talk_power_granted. This prevents local snapshot drift where fabricated defaults could hide password requirements, talk-power restrictions, or client type. * refactor: remove dead SnapshotChanged variant end-to-end SnapshotChanged is no longer emitted since the watchdog was refactored to liveness-only. Removes the variant from SessionEvent, BridgeEvent, and the Flutter switch statement. FRB bindings regenerated. * fix(ci): regenerate license inventory and fix iOS submodule fetch - Regenerate docs/security/license-inventory.md to match current lockfile - Remove submodules: true from checkout (causes hard fail on private submodule) - Add explicit git submodule update --init --depth=1 with || true fallback - Check silero-coreml/Package.swift instead of directory existence
This commit is contained in:
@@ -29,6 +29,7 @@ import 'services/ts3_server_link.dart';
|
||||
import 'services/ui_preferences_service.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
import 'widgets/permission_state_banner.dart';
|
||||
import 'widgets/audio_processing_config_state.dart';
|
||||
import 'widgets/chat_views.dart';
|
||||
@@ -648,13 +649,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
case rust.BridgeEvent_AudioStopped():
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
case rust.BridgeEvent_SnapshotChanged():
|
||||
setState(() {
|
||||
if (_phase == ConnectionPhase.synchronizing) {
|
||||
_phase = ConnectionPhase.connected;
|
||||
}
|
||||
});
|
||||
unawaited(_refreshSnapshot(recordActivity: true, reportErrors: true));
|
||||
case rust.BridgeEvent_PttCapability(
|
||||
:final level,
|
||||
:final backendId,
|
||||
@@ -690,7 +684,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
if (inChannel) {
|
||||
_ensureStatsTimer();
|
||||
unawaited(_onRefresh());
|
||||
} else {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
@@ -800,6 +793,72 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
case rust.BridgeEvent_AudioRouteChanged():
|
||||
break;
|
||||
case rust.BridgeEvent_ClientMoved(:final clientId, :final newChannelId):
|
||||
_applyClientDelta((c) => c.id == clientId, (c) {
|
||||
final updated = rust.BridgeClient(
|
||||
id: c.id,
|
||||
channel: newChannelId,
|
||||
name: c.name,
|
||||
inputMuted: c.inputMuted,
|
||||
outputMuted: c.outputMuted,
|
||||
isSpeaking: c.isSpeaking,
|
||||
isServerQuery: c.isServerQuery,
|
||||
talkPower: c.talkPower,
|
||||
talkPowerGranted: c.talkPowerGranted,
|
||||
);
|
||||
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,
|
||||
));
|
||||
case rust.BridgeEvent_ClientLeft(:final clientId):
|
||||
_applyClientRemove(clientId);
|
||||
case rust.BridgeEvent_ClientUpdated(:final clientId, :final inputMuted, :final outputMuted, :final isServerQuery, :final talkPower, :final talkPowerGranted):
|
||||
_applyClientDelta((c) => c.id == clientId, (c) {
|
||||
final updated = rust.BridgeClient(
|
||||
id: c.id,
|
||||
channel: c.channel,
|
||||
name: c.name,
|
||||
inputMuted: inputMuted,
|
||||
outputMuted: outputMuted,
|
||||
isSpeaking: c.isSpeaking,
|
||||
isServerQuery: isServerQuery,
|
||||
talkPower: talkPower,
|
||||
talkPowerGranted: talkPowerGranted,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
case rust.BridgeEvent_ChannelAdded(:final id, :final parent, :final name, :final order, :final hasPassword, :final neededTalkPower):
|
||||
_applyChannelAdd(rust.BridgeChannel(
|
||||
id: id,
|
||||
parent: parent,
|
||||
name: name,
|
||||
order: order,
|
||||
hasPassword: hasPassword,
|
||||
neededTalkPower: neededTalkPower,
|
||||
));
|
||||
case rust.BridgeEvent_ChannelRemoved(:final id):
|
||||
_applyChannelRemove(id);
|
||||
case rust.BridgeEvent_ChannelUpdated(:final id, :final name, :final hasPassword, :final neededTalkPower):
|
||||
_applyChannelDelta((ch) => ch.id == id, (ch) {
|
||||
return rust.BridgeChannel(
|
||||
id: ch.id,
|
||||
parent: ch.parent,
|
||||
name: name,
|
||||
order: ch.order,
|
||||
hasPassword: hasPassword,
|
||||
neededTalkPower: neededTalkPower,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,15 +1152,31 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_canJoinVoiceChannel = true;
|
||||
_voiceStateInitialized = true;
|
||||
});
|
||||
unawaited(_onRefresh());
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// Surface as a SnackBar so the user sees it even while
|
||||
// connected. The message is selected by TS3 error code per
|
||||
// the canonical catalogue at
|
||||
// https://github.com/ReSpeak/tsdeclarations.
|
||||
if (_isAlreadyInChannel(e)) {
|
||||
setState(() {
|
||||
_currentVoiceChannelId = ch.id;
|
||||
_pendingVoiceChannelId = null;
|
||||
_inChannel = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_isFlooding(e)) {
|
||||
setState(() {
|
||||
_pendingVoiceChannelId = null;
|
||||
});
|
||||
_showUiErrorSnackBar(
|
||||
area: 'join channel',
|
||||
error: e,
|
||||
displayMessage: l10n.channelJoinFailedFlooding,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final message = channelJoinErrorMessage(l10n, e);
|
||||
setState(() => _pendingVoiceChannelId = null);
|
||||
setState(() {
|
||||
_pendingVoiceChannelId = null;
|
||||
});
|
||||
_showUiErrorSnackBar(
|
||||
area: 'join channel',
|
||||
error: e,
|
||||
@@ -1110,6 +1185,20 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAlreadyInChannel(Object error) {
|
||||
if (error is rust_err.BridgeError_ServerRejected) {
|
||||
return error.code == 0x0302;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isFlooding(Object error) {
|
||||
if (error is rust_err.BridgeError_ServerRejected) {
|
||||
return error.code == 0x020c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _onToggleHardMute() async {
|
||||
// Don't allow manual unmute when talk-power-muted.
|
||||
if (_hardMuteByTalkPower && _hardMute) {
|
||||
@@ -1285,10 +1374,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh() async {
|
||||
await _refreshSnapshot(recordActivity: true, reportErrors: true);
|
||||
}
|
||||
|
||||
Future<void> _refreshSnapshot({
|
||||
required bool recordActivity,
|
||||
required bool reportErrors,
|
||||
@@ -1597,6 +1682,110 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
};
|
||||
}
|
||||
|
||||
void _applyClientDelta(bool Function(rust.BridgeClient) test, rust.BridgeClient Function(rust.BridgeClient) update) {
|
||||
final snap = _snapshot;
|
||||
if (snap == null) return;
|
||||
final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList();
|
||||
setState(() {
|
||||
_snapshot = rust.BridgeSnapshot(
|
||||
serverName: snap.serverName,
|
||||
welcomeMessage: snap.welcomeMessage,
|
||||
platform: snap.platform,
|
||||
version: snap.version,
|
||||
channels: snap.channels,
|
||||
clients: clients,
|
||||
ownClientId: snap.ownClientId,
|
||||
);
|
||||
_notifyChatFeedChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyClientAdd(rust.BridgeClient client) {
|
||||
final snap = _snapshot;
|
||||
if (snap == null) return;
|
||||
setState(() {
|
||||
_snapshot = rust.BridgeSnapshot(
|
||||
serverName: snap.serverName,
|
||||
welcomeMessage: snap.welcomeMessage,
|
||||
platform: snap.platform,
|
||||
version: snap.version,
|
||||
channels: snap.channels,
|
||||
clients: [...snap.clients, client],
|
||||
ownClientId: snap.ownClientId,
|
||||
);
|
||||
_notifyChatFeedChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyClientRemove(BigInt clientId) {
|
||||
final snap = _snapshot;
|
||||
if (snap == null) return;
|
||||
setState(() {
|
||||
_snapshot = rust.BridgeSnapshot(
|
||||
serverName: snap.serverName,
|
||||
welcomeMessage: snap.welcomeMessage,
|
||||
platform: snap.platform,
|
||||
version: snap.version,
|
||||
channels: snap.channels,
|
||||
clients: snap.clients.where((c) => c.id != clientId).toList(),
|
||||
ownClientId: snap.ownClientId,
|
||||
);
|
||||
_notifyChatFeedChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyChannelAdd(rust.BridgeChannel channel) {
|
||||
final snap = _snapshot;
|
||||
if (snap == null) return;
|
||||
setState(() {
|
||||
_snapshot = rust.BridgeSnapshot(
|
||||
serverName: snap.serverName,
|
||||
welcomeMessage: snap.welcomeMessage,
|
||||
platform: snap.platform,
|
||||
version: snap.version,
|
||||
channels: [...snap.channels, channel],
|
||||
clients: snap.clients,
|
||||
ownClientId: snap.ownClientId,
|
||||
);
|
||||
_notifyChatFeedChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyChannelRemove(BigInt channelId) {
|
||||
final snap = _snapshot;
|
||||
if (snap == null) return;
|
||||
setState(() {
|
||||
_snapshot = rust.BridgeSnapshot(
|
||||
serverName: snap.serverName,
|
||||
welcomeMessage: snap.welcomeMessage,
|
||||
platform: snap.platform,
|
||||
version: snap.version,
|
||||
channels: snap.channels.where((ch) => ch.id != channelId).toList(),
|
||||
clients: snap.clients,
|
||||
ownClientId: snap.ownClientId,
|
||||
);
|
||||
_notifyChatFeedChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyChannelDelta(bool Function(rust.BridgeChannel) test, rust.BridgeChannel Function(rust.BridgeChannel) update) {
|
||||
final snap = _snapshot;
|
||||
if (snap == null) return;
|
||||
final channels = snap.channels.map((ch) => test(ch) ? update(ch) : ch).toList();
|
||||
setState(() {
|
||||
_snapshot = rust.BridgeSnapshot(
|
||||
serverName: snap.serverName,
|
||||
welcomeMessage: snap.welcomeMessage,
|
||||
platform: snap.platform,
|
||||
version: snap.version,
|
||||
channels: channels,
|
||||
clients: snap.clients,
|
||||
ownClientId: snap.ownClientId,
|
||||
);
|
||||
_notifyChatFeedChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _applySnapshot(rust.BridgeSnapshot snap) {
|
||||
_snapshot = snap;
|
||||
_phase = phaseAfterSnapshotApplied(_phase);
|
||||
|
||||
Reference in New Issue
Block a user