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:
Edison Jwa
2026-06-03 18:20:33 +09:00
committed by GitHub
parent 2c3c3873dc
commit 808324f374
14 changed files with 1781 additions and 434 deletions
+207 -18
View File
@@ -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);