feat: stabilize voice activity and audio routing
This commit is contained in:
@@ -76,12 +76,18 @@ String _kAppVersion = appSemverBaseline;
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await RustLib.init();
|
||||
_kAppVersion = await resolveAppVersion();
|
||||
unawaited(wireStorage());
|
||||
unawaited(wireConnectivity());
|
||||
wireAudioLifecycle();
|
||||
await configureBundledVadModels();
|
||||
runApp(const ChanoraApp());
|
||||
unawaited(_finishDeferredStartup());
|
||||
}
|
||||
|
||||
Future<void> _finishDeferredStartup() async {
|
||||
try {
|
||||
_kAppVersion = await resolveAppVersion();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
class ChanoraApp extends StatelessWidget {
|
||||
@@ -149,7 +155,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
rust.BridgeAudioStats? _audioStats;
|
||||
Timer? _statsTimer;
|
||||
int _statsTick = 0;
|
||||
bool _voiceStatusRefreshInFlight = false;
|
||||
bool _snapshotRefreshInFlight = false;
|
||||
bool _snapshotRefreshQueued = false;
|
||||
bool _snapshotRefreshQueuedRecordActivity = false;
|
||||
bool _snapshotRefreshQueuedReportErrors = false;
|
||||
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
||||
|
||||
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
|
||||
@@ -164,6 +173,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
BigInt? _currentVoiceChannelId;
|
||||
BigInt? _pendingVoiceChannelId;
|
||||
bool _canJoinVoiceChannel = true;
|
||||
bool _voiceStateInitialized = false;
|
||||
|
||||
String? _lostReason;
|
||||
int? _reconnectAttempt;
|
||||
@@ -193,13 +203,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
List<rust.BridgeBookmark> _bookmarks = const [];
|
||||
final List<ChatEntry> _chatMessages = [];
|
||||
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
||||
int _chatUnread = 0;
|
||||
bool _chatOpen = false;
|
||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
||||
const [],
|
||||
);
|
||||
bool _pokeSnackBarVisible = false;
|
||||
IconData? _audioRoute;
|
||||
|
||||
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
|
||||
// Constructed at startup so cold-launch state is captured before the
|
||||
@@ -421,6 +431,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
Future<void> _reloadBookmarks() async {
|
||||
try {
|
||||
await wireStorage();
|
||||
final list = await rust.listBookmarks();
|
||||
if (!mounted) return;
|
||||
setState(() => _bookmarks = list);
|
||||
@@ -467,7 +478,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_phase = ConnectionPhase.connected;
|
||||
}
|
||||
});
|
||||
unawaited(_onRefresh());
|
||||
unawaited(_refreshSnapshot(recordActivity: true, reportErrors: true));
|
||||
case rust.BridgeEvent_PttCapability(
|
||||
:final level,
|
||||
:final backendId,
|
||||
@@ -491,6 +502,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
:final canJoin,
|
||||
):
|
||||
setState(() {
|
||||
_voiceStateInitialized = true;
|
||||
_inChannel = inChannel;
|
||||
_transmitMode = transmitMode;
|
||||
_hardMute = mute;
|
||||
@@ -565,7 +577,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final isPoke = target is rust.BridgeMessageTarget_Poke;
|
||||
final receivedAt = DateTime.now();
|
||||
setState(() {
|
||||
_chatMessages.add(
|
||||
_appendChatEntryUnlocked(
|
||||
ChatEntry(
|
||||
senderId: senderId,
|
||||
senderName: senderName,
|
||||
@@ -575,12 +587,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
timestamp: receivedAt,
|
||||
),
|
||||
);
|
||||
if (_chatMessages.length > 200) {
|
||||
_chatMessages.removeRange(0, _chatMessages.length - 200);
|
||||
}
|
||||
if (!_chatOpen && !isPoke) {
|
||||
_chatUnread++;
|
||||
}
|
||||
});
|
||||
if (isPoke) {
|
||||
_showPokeSnackBar(
|
||||
@@ -601,8 +607,21 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
);
|
||||
}());
|
||||
}
|
||||
case rust.BridgeEvent_AudioRouteChanged(:final route):
|
||||
setState(() => _audioRoute = _routeIcon(route));
|
||||
case rust.BridgeEvent_ServerActivity(:final message):
|
||||
setState(() {
|
||||
_appendChatEntryUnlocked(
|
||||
ChatEntry(
|
||||
senderId: BigInt.zero,
|
||||
senderName: 'Server',
|
||||
message: message,
|
||||
target: const rust.BridgeMessageTarget.server(),
|
||||
timestamp: DateTime.now(),
|
||||
countsTowardUnread: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
case rust.BridgeEvent_AudioRouteChanged():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,13 +636,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
void _ensureStatsTimer() {
|
||||
if (_statsTimer != null) return;
|
||||
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
|
||||
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
||||
try {
|
||||
final s = await rust.audioStats();
|
||||
if (!mounted) return;
|
||||
setState(() => _audioStats = s);
|
||||
_statsTick += 1;
|
||||
if (_statsTick % 5 == 0) {
|
||||
if (_statsTick % 4 == 0) {
|
||||
unawaited(_refreshSnapshotForVoiceStatus());
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -631,18 +650,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Future<void> _refreshSnapshotForVoiceStatus() async {
|
||||
if (_voiceStatusRefreshInFlight) return;
|
||||
_voiceStatusRefreshInFlight = true;
|
||||
try {
|
||||
final snap = await rust.snapshot();
|
||||
if (!mounted) return;
|
||||
setState(() => _applySnapshot(snap));
|
||||
} catch (_) {
|
||||
// Best-effort visual refresh only. Connection/loss paths still
|
||||
// surface via the normal bridge events and explicit refreshes.
|
||||
} finally {
|
||||
_voiceStatusRefreshInFlight = false;
|
||||
}
|
||||
await _refreshSnapshot(recordActivity: true, reportErrors: false);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -669,10 +677,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
|
||||
_eventsSub?.cancel();
|
||||
_statsTimer?.cancel();
|
||||
_voiceStatusRefreshInFlight = false;
|
||||
_snapshotRefreshInFlight = false;
|
||||
_snapshotRefreshQueued = false;
|
||||
_snapshotRefreshQueuedRecordActivity = false;
|
||||
_snapshotRefreshQueuedReportErrors = false;
|
||||
_hostCtl.dispose();
|
||||
_nickCtl.dispose();
|
||||
_passwordCtl.dispose();
|
||||
_chatFeedRevision.dispose();
|
||||
_pokeSnackBarPokes.dispose();
|
||||
_androidPermissions.recordAudioState.removeListener(
|
||||
_onRecordAudioPermissionChanged,
|
||||
@@ -880,9 +892,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
}
|
||||
await configureBundledVadModels();
|
||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_currentVoiceChannelId = ch.id;
|
||||
_pendingVoiceChannelId = null;
|
||||
_inChannel = true;
|
||||
_canJoinVoiceChannel = true;
|
||||
_voiceStateInitialized = true;
|
||||
});
|
||||
unawaited(_onRefresh());
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -959,10 +977,25 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
try {
|
||||
return await rust.getAudioProcessingConfig();
|
||||
} catch (_) {
|
||||
return defaultAudioProcessingConfig;
|
||||
return defaultAudioProcessingConfig();
|
||||
}
|
||||
}
|
||||
|
||||
void _appendChatEntryUnlocked(ChatEntry entry) {
|
||||
_chatMessages.add(entry);
|
||||
if (_chatMessages.length > 200) {
|
||||
_chatMessages.removeRange(0, _chatMessages.length - 200);
|
||||
}
|
||||
_notifyChatFeedChanged();
|
||||
if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) {
|
||||
_chatUnread++;
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyChatFeedChanged() {
|
||||
_chatFeedRevision.value++;
|
||||
}
|
||||
|
||||
/// Narrow-mode voice controls modal sheet (Plan E status chip
|
||||
/// trigger). On mobile this is the **single** voice-controls
|
||||
/// surface: route picker + inline mode radio + inline release-tail
|
||||
@@ -1061,13 +1094,65 @@ 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,
|
||||
}) async {
|
||||
if (_snapshotRefreshInFlight) {
|
||||
_snapshotRefreshQueued = true;
|
||||
_snapshotRefreshQueuedRecordActivity =
|
||||
_snapshotRefreshQueuedRecordActivity || recordActivity;
|
||||
_snapshotRefreshQueuedReportErrors =
|
||||
_snapshotRefreshQueuedReportErrors || reportErrors;
|
||||
return;
|
||||
}
|
||||
|
||||
_snapshotRefreshInFlight = true;
|
||||
var nextRecordActivity = recordActivity;
|
||||
var nextReportErrors = reportErrors;
|
||||
|
||||
try {
|
||||
final snap = await rust.snapshot();
|
||||
if (!mounted) return;
|
||||
setState(() => _applySnapshot(snap));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showUiError('refresh snapshot', e);
|
||||
while (mounted) {
|
||||
_snapshotRefreshQueued = false;
|
||||
_snapshotRefreshQueuedRecordActivity = false;
|
||||
_snapshotRefreshQueuedReportErrors = false;
|
||||
|
||||
final previousSnapshot = _snapshot;
|
||||
try {
|
||||
final snap = await rust.snapshot();
|
||||
if (!mounted) return;
|
||||
final activityEntries = nextRecordActivity && previousSnapshot != null
|
||||
? buildServerActivityEntries(
|
||||
previous: previousSnapshot,
|
||||
current: snap,
|
||||
)
|
||||
: const <ChatEntry>[];
|
||||
setState(() {
|
||||
_applySnapshot(snap);
|
||||
for (final entry in activityEntries) {
|
||||
_appendChatEntryUnlocked(entry);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (nextReportErrors && mounted) {
|
||||
_showUiError('refresh snapshot', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_snapshotRefreshQueued) {
|
||||
break;
|
||||
}
|
||||
nextRecordActivity = _snapshotRefreshQueuedRecordActivity;
|
||||
nextReportErrors = _snapshotRefreshQueuedReportErrors;
|
||||
}
|
||||
} finally {
|
||||
_snapshotRefreshInFlight = false;
|
||||
_snapshotRefreshQueued = false;
|
||||
_snapshotRefreshQueuedRecordActivity = false;
|
||||
_snapshotRefreshQueuedReportErrors = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1109,7 +1194,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
_statsTick = 0;
|
||||
_voiceStatusRefreshInFlight = false;
|
||||
_snapshotRefreshInFlight = false;
|
||||
_snapshotRefreshQueued = false;
|
||||
_snapshotRefreshQueuedRecordActivity = false;
|
||||
_snapshotRefreshQueuedReportErrors = false;
|
||||
try {
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
@@ -1131,34 +1219,21 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_currentVoiceChannelId = null;
|
||||
_pendingVoiceChannelId = null;
|
||||
_canJoinVoiceChannel = true;
|
||||
_voiceStateInitialized = false;
|
||||
_lostReason = null;
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
_chatMessages.clear();
|
||||
_chatUnread = 0;
|
||||
_chatOpen = false;
|
||||
}
|
||||
|
||||
IconData _routeIcon(rust.BridgeAudioRoute r) {
|
||||
switch (r) {
|
||||
case rust.BridgeAudioRoute.earpiece:
|
||||
return Icons.phone_android;
|
||||
case rust.BridgeAudioRoute.speaker:
|
||||
return Icons.volume_up;
|
||||
case rust.BridgeAudioRoute.wiredHeadset:
|
||||
return Icons.headset;
|
||||
case rust.BridgeAudioRoute.bluetoothHfp:
|
||||
case rust.BridgeAudioRoute.bluetoothA2Dp:
|
||||
return Icons.bluetooth;
|
||||
case rust.BridgeAudioRoute.unknown:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
_notifyChatFeedChanged();
|
||||
}
|
||||
|
||||
Future<void> _onOpenChat({
|
||||
rust.BridgeMessageTarget? target,
|
||||
String clientName = '',
|
||||
}) async {
|
||||
final initialSnapshot = _snapshot!;
|
||||
setState(() {
|
||||
_chatUnread = 0;
|
||||
_chatOpen = true;
|
||||
@@ -1167,7 +1242,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChatPage(
|
||||
messages: _chatMessages,
|
||||
snapshot: _snapshot!,
|
||||
snapshot: initialSnapshot,
|
||||
messagesSource: () => _chatMessages,
|
||||
snapshotSource: () => _snapshot ?? initialSnapshot,
|
||||
refreshListenable: _chatFeedRevision,
|
||||
initialTarget:
|
||||
target ??
|
||||
resolveInitialChatTarget(
|
||||
@@ -1312,12 +1390,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_snapshot = snap;
|
||||
final own = ownClientSnapshotState(snap);
|
||||
if (own == null) return;
|
||||
_currentVoiceChannelId = own.channelId;
|
||||
_inputMuted = own.inputMuted;
|
||||
_outputMuted = own.outputMuted;
|
||||
_pendingVoiceChannelId = null;
|
||||
_inChannel = true;
|
||||
_canJoinVoiceChannel = true;
|
||||
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
|
||||
_currentVoiceChannelId = own.channelId;
|
||||
_pendingVoiceChannelId = null;
|
||||
_inChannel = true;
|
||||
_canJoinVoiceChannel = true;
|
||||
_voiceStateInitialized = true;
|
||||
} else if (_pendingVoiceChannelId == null) {
|
||||
_currentVoiceChannelId = own.channelId;
|
||||
}
|
||||
_notifyChatFeedChanged();
|
||||
|
||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||
_hardMuteByTalkPower = true;
|
||||
@@ -1525,6 +1609,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (name == null || name.trim().isEmpty) return;
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await wireStorage();
|
||||
await rust.addBookmark(
|
||||
b: rust.BridgeBookmark(
|
||||
id: 0,
|
||||
@@ -1543,6 +1628,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
|
||||
try {
|
||||
await wireStorage();
|
||||
await rust.deleteBookmark(id: b.id);
|
||||
await _reloadBookmarks();
|
||||
} catch (e) {
|
||||
@@ -1580,6 +1666,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
try {
|
||||
await wireStorage();
|
||||
await rust.addBookmark(
|
||||
b: rust.BridgeBookmark(
|
||||
id: 0,
|
||||
@@ -1652,7 +1739,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final appBarTitle = _serverReachable
|
||||
? Text(
|
||||
_snapshot?.serverName ?? l10n.appTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: headerTitle;
|
||||
|
||||
@@ -1701,10 +1792,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_serverReachable && _audioRoute != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(_audioRoute, size: 16, color: theme.colorScheme.tertiary),
|
||||
],
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -1925,6 +2012,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
onPressed: _onConfirmDisconnect,
|
||||
)
|
||||
: null,
|
||||
leadingWidth: _phase.canDisconnect ? 44 : null,
|
||||
titleSpacing: _phase.canDisconnect ? 4 : null,
|
||||
title: appBarTitle,
|
||||
actions: headerActions,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user