fix(voice): preserve mute owners and release touch ptt
This commit is contained in:
@@ -25,6 +25,7 @@ import 'services/audio_lifecycle_service.dart';
|
||||
import 'services/ios_audio_session_controller.dart';
|
||||
import 'services/channel_join_error_mapper.dart';
|
||||
import 'services/connection_phase_state.dart';
|
||||
import 'services/hard_mute_owners.dart';
|
||||
import 'services/ios_permissions_service.dart';
|
||||
import 'services/macos_permissions_service.dart';
|
||||
import 'services/prefetch_debouncer.dart';
|
||||
@@ -339,8 +340,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
bool _inChannel = false;
|
||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||
bool _hardMute = false;
|
||||
bool _hardMuteByPermission = false;
|
||||
bool _hardMuteByTalkPower = false;
|
||||
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
||||
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
||||
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
||||
bool _permissionHardMuteClearInFlight = false;
|
||||
int _releaseTailMs = 200;
|
||||
BigInt? _currentVoiceChannelId;
|
||||
@@ -590,8 +592,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: false);
|
||||
if (!mounted || !_hardMuteByPermission) return;
|
||||
setState(() {
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -798,8 +800,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_voiceStateInitialized = true;
|
||||
_inChannel = inChannel;
|
||||
_transmitMode = transmitMode;
|
||||
_hardMute = mute;
|
||||
if (!mute) _hardMuteByPermission = false;
|
||||
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_releaseTailMs = releaseTailMs;
|
||||
_currentVoiceChannelId = currentChannelId;
|
||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||
@@ -1271,8 +1273,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hardMute = true;
|
||||
_hardMuteByPermission = true;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -1287,8 +1289,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1356,13 +1358,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final next = !_hardMute;
|
||||
final previousInputMuted = _inputMuted;
|
||||
final previousHardMute = _hardMute;
|
||||
final previousPermissionMute = _hardMuteByPermission;
|
||||
final previousHardMuteOwners = _hardMuteOwners;
|
||||
setState(() {
|
||||
_inputMuted = next;
|
||||
_hardMute = next;
|
||||
if (next) {
|
||||
_hardMuteByPermission = false;
|
||||
}
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(
|
||||
manual: next,
|
||||
permission: next ? false : null,
|
||||
);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
});
|
||||
try {
|
||||
// Hard-mute is two coordinated effects:
|
||||
@@ -1381,7 +1384,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
setState(() {
|
||||
_inputMuted = previousInputMuted;
|
||||
_hardMute = previousHardMute;
|
||||
_hardMuteByPermission = previousPermissionMute;
|
||||
_hardMuteOwners = previousHardMuteOwners;
|
||||
});
|
||||
_showUiError('hard mute', e);
|
||||
}
|
||||
@@ -1651,8 +1654,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_inputMuted = false;
|
||||
_outputMuted = false;
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
_hardMuteByTalkPower = false;
|
||||
_hardMuteOwners = const HardMuteOwners();
|
||||
_inChannel = false;
|
||||
_currentVoiceChannelId = null;
|
||||
_pendingVoiceChannelId = null;
|
||||
@@ -2083,8 +2085,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||
final talkPowerEpoch = _connectionEpoch;
|
||||
_hardMuteByTalkPower = true;
|
||||
_hardMute = true;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
_connectionEpoch != talkPowerEpoch ||
|
||||
@@ -2097,15 +2099,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||
final talkPowerEpoch = _connectionEpoch;
|
||||
_hardMuteByTalkPower = false;
|
||||
if (!_hardMuteByPermission) {
|
||||
_hardMute = false;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
if (!_hardMuteOwners.effective) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
_connectionEpoch != talkPowerEpoch ||
|
||||
!_serverReachable ||
|
||||
_hardMuteByTalkPower ||
|
||||
_hardMuteByPermission) {
|
||||
_hardMuteOwners.effective) {
|
||||
return;
|
||||
}
|
||||
unawaited(rust.setHardMute(muted: false));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class HardMuteOwners {
|
||||
const HardMuteOwners({
|
||||
this.manual = false,
|
||||
this.permission = false,
|
||||
this.talkPower = false,
|
||||
});
|
||||
|
||||
final bool manual;
|
||||
final bool permission;
|
||||
final bool talkPower;
|
||||
|
||||
bool get effective => manual || permission || talkPower;
|
||||
|
||||
HardMuteOwners withBridgeManualMute(bool muted) {
|
||||
return copyWith(
|
||||
manual: muted && (manual || !permission && !talkPower),
|
||||
);
|
||||
}
|
||||
|
||||
HardMuteOwners copyWith({
|
||||
bool? manual,
|
||||
bool? permission,
|
||||
bool? talkPower,
|
||||
}) {
|
||||
return HardMuteOwners(
|
||||
manual: manual ?? this.manual,
|
||||
permission: permission ?? this.permission,
|
||||
talkPower: talkPower ?? this.talkPower,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ class IosAudioSessionController {
|
||||
// exception to the event handler would be noise. The Rust
|
||||
// engine remains alive and will produce silence until the
|
||||
// next route change or a manual leave/rejoin.
|
||||
} on MissingPluginException {
|
||||
// Test hosts and mispackaged builds may not have registered
|
||||
// the iOS channel. Keep event dispatch alive rather than
|
||||
// surfacing an unhandled async error.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +53,9 @@ class IosAudioSessionController {
|
||||
// Worst case the session stays in .playAndRecord until the
|
||||
// app is backgrounded — at which point iOS reclaims the
|
||||
// session automatically.
|
||||
} on MissingPluginException {
|
||||
// Same rationale as activate(): missing channel should not
|
||||
// break bridge event handling.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +318,15 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
||||
playVoicePttHaptic(held);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_pressed) {
|
||||
_pressed = false;
|
||||
widget.onHeldChanged(false);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
||||
|
||||
void main() {
|
||||
group('HardMuteOwners', () {
|
||||
test('manual mute survives talk-power block and restore', () {
|
||||
const owners = HardMuteOwners(manual: true);
|
||||
|
||||
final blocked = owners.copyWith(talkPower: true);
|
||||
expect(blocked.effective, isTrue);
|
||||
|
||||
final restored = blocked.copyWith(talkPower: false);
|
||||
expect(restored.manual, isTrue);
|
||||
expect(restored.talkPower, isFalse);
|
||||
expect(restored.effective, isTrue);
|
||||
});
|
||||
|
||||
test('effective mute is the union of independent owners', () {
|
||||
expect(const HardMuteOwners().effective, isFalse);
|
||||
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
||||
});
|
||||
|
||||
test('bridge mute does not convert talk-power owner into manual owner', () {
|
||||
const owners = HardMuteOwners(talkPower: true);
|
||||
|
||||
final synced = owners.withBridgeManualMute(true);
|
||||
|
||||
expect(synced.manual, isFalse);
|
||||
expect(synced.talkPower, isTrue);
|
||||
expect(synced.effective, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -84,5 +84,16 @@ void main() {
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
|
||||
test('activate swallows MissingPluginException when channel is absent',
|
||||
() async {
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
||||
final heldChanges = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(
|
||||
active: false,
|
||||
onHeldChanged: heldChanges.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
|
||||
expect(heldChanges, [true]);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
|
||||
await gesture.cancel();
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user