feat(voice): harden Android audio and channel joins
This commit is contained in:
@@ -252,6 +252,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
bool _hardMute = false;
|
||||
int _releaseTailMs = 200;
|
||||
BigInt? _currentVoiceChannelId;
|
||||
BigInt? _pendingVoiceChannelId;
|
||||
bool _canJoinVoiceChannel = true;
|
||||
bool _canLeaveVoiceChannel = false;
|
||||
|
||||
String? _lostReason;
|
||||
int? _reconnectAttempt;
|
||||
@@ -312,7 +315,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
bool _handleFocusedPttKey(KeyEvent event) {
|
||||
final label = _pttDisplayLabelForKey(event.logicalKey);
|
||||
final isBoundKey = _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
|
||||
final isBoundKey =
|
||||
_pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
|
||||
if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
|
||||
if (_focusedPttHeldKeys.remove(event.logicalKey)) {
|
||||
unawaited(_setPtt(false));
|
||||
@@ -423,6 +427,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
:final transmitMode,
|
||||
:final mute,
|
||||
:final releaseTailMs,
|
||||
:final currentChannelId,
|
||||
:final pendingTargetChannelId,
|
||||
:final canJoin,
|
||||
:final canLeave,
|
||||
):
|
||||
setState(() {
|
||||
_inChannel = inChannel;
|
||||
@@ -430,6 +438,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_hardMute = mute;
|
||||
_releaseTailMs = releaseTailMs;
|
||||
_audioStarted = inChannel;
|
||||
_currentVoiceChannelId = currentChannelId;
|
||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||
_canJoinVoiceChannel = canJoin;
|
||||
_canLeaveVoiceChannel = canLeave;
|
||||
});
|
||||
if (inChannel) {
|
||||
_ensureStatsTimer();
|
||||
@@ -438,7 +450,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
_releaseFocusedPttIfHeld();
|
||||
setState(() => _currentVoiceChannelId = null);
|
||||
}
|
||||
if (transmitMode != rust.BridgeTransmitMode.ptt) {
|
||||
_releaseFocusedPttIfHeld();
|
||||
@@ -485,10 +496,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
// remains the canonical Dart-side state holder (driven by
|
||||
// the MethodChannel); a future revision may expose a
|
||||
// setter so both paths converge on a single ValueNotifier.
|
||||
case rust.BridgeEvent_PermissionState(
|
||||
:final permission,
|
||||
:final state,
|
||||
):
|
||||
case rust.BridgeEvent_PermissionState(:final permission, :final state):
|
||||
debugPrint(
|
||||
'bridge permission_state: permission=$permission state=$state',
|
||||
);
|
||||
@@ -568,27 +576,27 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
void _showPermissionDeniedDialog(String rawError) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final isNetwork =
|
||||
rawError.contains('9987') || rawError.contains('connect');
|
||||
final isNetwork = rawError.contains('9987') || rawError.contains('connect');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title:
|
||||
Text(isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied),
|
||||
title: Text(
|
||||
isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied,
|
||||
),
|
||||
content: Text(
|
||||
isNetwork ? l10n.networkPermissionBody : l10n.microphonePermissionBody),
|
||||
isNetwork
|
||||
? l10n.networkPermissionBody
|
||||
: l10n.microphonePermissionBody,
|
||||
),
|
||||
actions: [
|
||||
if (Platform.isMacOS)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
try {
|
||||
Process.run(
|
||||
'open',
|
||||
[
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
|
||||
],
|
||||
);
|
||||
Process.run('open', [
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
|
||||
]);
|
||||
} catch (_) {}
|
||||
},
|
||||
child: Text(l10n.networkPermissionOpenSettings),
|
||||
@@ -649,6 +657,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
|
||||
if (!_canJoinVoiceChannel) return;
|
||||
if (_pendingVoiceChannelId != null) return;
|
||||
if (ch.id == _currentVoiceChannelId) return;
|
||||
final l10n = AppL10n.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
@@ -689,7 +699,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||
if (!mounted) return;
|
||||
setState(() => _currentVoiceChannelId = ch.id);
|
||||
unawaited(_onRefresh());
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
// Surface as a SnackBar so the user sees it even while
|
||||
@@ -753,10 +763,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
// ignore: unused_element
|
||||
Future<void> _onLeaveVoice() async {
|
||||
if (!_canLeaveVoiceChannel) return;
|
||||
try {
|
||||
await rust.voiceLeave();
|
||||
if (!mounted) return;
|
||||
setState(() => _currentVoiceChannelId = null);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
@@ -916,6 +925,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_outputMuted = false;
|
||||
_inChannel = false;
|
||||
_currentVoiceChannelId = null;
|
||||
_pendingVoiceChannelId = null;
|
||||
_canJoinVoiceChannel = true;
|
||||
_canLeaveVoiceChannel = false;
|
||||
});
|
||||
_releaseFocusedPttIfHeld();
|
||||
}
|
||||
@@ -932,17 +944,6 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
void _applySnapshot(rust.BridgeSnapshot snap) {
|
||||
_snapshot = snap;
|
||||
if (!_inChannel) {
|
||||
_currentVoiceChannelId = null;
|
||||
return;
|
||||
}
|
||||
for (final client in snap.clients) {
|
||||
if (client.id == snap.ownClientId) {
|
||||
_currentVoiceChannelId = client.channel;
|
||||
return;
|
||||
}
|
||||
}
|
||||
_currentVoiceChannelId = null;
|
||||
}
|
||||
|
||||
Future<void> _onShowDiagnostics(BuildContext context) async {
|
||||
@@ -1335,6 +1336,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
pendingVoiceChannelId: _pendingVoiceChannelId,
|
||||
hasJoinPending: _pendingVoiceChannelId != null,
|
||||
canJoinVoiceChannel: _canJoinVoiceChannel,
|
||||
canLeaveVoiceChannel: _canLeaveVoiceChannel,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
onLeaveVoice: _onLeaveVoice,
|
||||
);
|
||||
@@ -1346,8 +1351,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
@@ -1376,8 +1380,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
_transmitMode ==
|
||||
rust.BridgeTransmitMode.ptt) ...[
|
||||
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
@@ -1410,7 +1413,12 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: Padding(padding: const EdgeInsets.all(16), child: bodyContent)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: bodyContent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1418,14 +1426,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: headerTitle,
|
||||
actions: headerActions,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: bodyContent,
|
||||
),
|
||||
appBar: AppBar(title: headerTitle, actions: headerActions),
|
||||
body: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1991,12 +1993,20 @@ class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
required this.snapshot,
|
||||
required this.currentVoiceChannelId,
|
||||
required this.pendingVoiceChannelId,
|
||||
required this.hasJoinPending,
|
||||
required this.canJoinVoiceChannel,
|
||||
required this.canLeaveVoiceChannel,
|
||||
required this.onJoinChannel,
|
||||
required this.onLeaveVoice,
|
||||
});
|
||||
|
||||
final rust.BridgeSnapshot snapshot;
|
||||
final BigInt? currentVoiceChannelId;
|
||||
final BigInt? pendingVoiceChannelId;
|
||||
final bool hasJoinPending;
|
||||
final bool canJoinVoiceChannel;
|
||||
final bool canLeaveVoiceChannel;
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||
final VoidCallback onLeaveVoice;
|
||||
|
||||
@@ -2077,17 +2087,27 @@ class _SnapshotView extends StatelessWidget {
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
ch.id == currentVoiceChannelId ? Icons.logout : Icons.login,
|
||||
ch.id == currentVoiceChannelId
|
||||
? Icons.logout
|
||||
: ch.id == pendingVoiceChannelId
|
||||
? Icons.hourglass_top
|
||||
: Icons.login,
|
||||
),
|
||||
tooltip: ch.id == currentVoiceChannelId
|
||||
? l10n.leaveChannelAction
|
||||
: ch.id == pendingVoiceChannelId
|
||||
? l10n.statusConnecting
|
||||
: l10n.joinChannelAction,
|
||||
onPressed: ch.id == currentVoiceChannelId
|
||||
? onLeaveVoice
|
||||
onPressed: hasJoinPending
|
||||
? null
|
||||
: ch.id == pendingVoiceChannelId
|
||||
? null
|
||||
: ch.id == currentVoiceChannelId
|
||||
? (canLeaveVoiceChannel ? onLeaveVoice : null)
|
||||
: () => onJoinChannel(ch),
|
||||
),
|
||||
selected: ch.id == currentVoiceChannelId,
|
||||
onTap: ch.id == currentVoiceChannelId
|
||||
onTap: hasJoinPending || !canJoinVoiceChannel || ch.id == currentVoiceChannelId
|
||||
? null
|
||||
: () => onJoinChannel(ch),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user