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/ios_audio_session_controller.dart';
|
||||||
import 'services/channel_join_error_mapper.dart';
|
import 'services/channel_join_error_mapper.dart';
|
||||||
import 'services/connection_phase_state.dart';
|
import 'services/connection_phase_state.dart';
|
||||||
|
import 'services/hard_mute_owners.dart';
|
||||||
import 'services/ios_permissions_service.dart';
|
import 'services/ios_permissions_service.dart';
|
||||||
import 'services/macos_permissions_service.dart';
|
import 'services/macos_permissions_service.dart';
|
||||||
import 'services/prefetch_debouncer.dart';
|
import 'services/prefetch_debouncer.dart';
|
||||||
@@ -339,8 +340,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
bool _inChannel = false;
|
bool _inChannel = false;
|
||||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||||
bool _hardMute = false;
|
bool _hardMute = false;
|
||||||
bool _hardMuteByPermission = false;
|
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
||||||
bool _hardMuteByTalkPower = false;
|
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
||||||
|
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
||||||
bool _permissionHardMuteClearInFlight = false;
|
bool _permissionHardMuteClearInFlight = false;
|
||||||
int _releaseTailMs = 200;
|
int _releaseTailMs = 200;
|
||||||
BigInt? _currentVoiceChannelId;
|
BigInt? _currentVoiceChannelId;
|
||||||
@@ -590,8 +592,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (!mounted || !_hardMuteByPermission) return;
|
if (!mounted || !_hardMuteByPermission) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = false;
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||||
_hardMuteByPermission = false;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -798,8 +800,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_voiceStateInitialized = true;
|
_voiceStateInitialized = true;
|
||||||
_inChannel = inChannel;
|
_inChannel = inChannel;
|
||||||
_transmitMode = transmitMode;
|
_transmitMode = transmitMode;
|
||||||
_hardMute = mute;
|
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
||||||
if (!mute) _hardMuteByPermission = false;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
_releaseTailMs = releaseTailMs;
|
_releaseTailMs = releaseTailMs;
|
||||||
_currentVoiceChannelId = currentChannelId;
|
_currentVoiceChannelId = currentChannelId;
|
||||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||||
@@ -1271,8 +1273,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: true);
|
await rust.setHardMute(muted: true);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = true;
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
||||||
_hardMuteByPermission = true;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -1287,8 +1289,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = false;
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||||
_hardMuteByPermission = false;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1356,13 +1358,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final next = !_hardMute;
|
final next = !_hardMute;
|
||||||
final previousInputMuted = _inputMuted;
|
final previousInputMuted = _inputMuted;
|
||||||
final previousHardMute = _hardMute;
|
final previousHardMute = _hardMute;
|
||||||
final previousPermissionMute = _hardMuteByPermission;
|
final previousHardMuteOwners = _hardMuteOwners;
|
||||||
setState(() {
|
setState(() {
|
||||||
_inputMuted = next;
|
_inputMuted = next;
|
||||||
_hardMute = next;
|
_hardMuteOwners = _hardMuteOwners.copyWith(
|
||||||
if (next) {
|
manual: next,
|
||||||
_hardMuteByPermission = false;
|
permission: next ? false : null,
|
||||||
}
|
);
|
||||||
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// Hard-mute is two coordinated effects:
|
// Hard-mute is two coordinated effects:
|
||||||
@@ -1381,7 +1384,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_inputMuted = previousInputMuted;
|
_inputMuted = previousInputMuted;
|
||||||
_hardMute = previousHardMute;
|
_hardMute = previousHardMute;
|
||||||
_hardMuteByPermission = previousPermissionMute;
|
_hardMuteOwners = previousHardMuteOwners;
|
||||||
});
|
});
|
||||||
_showUiError('hard mute', e);
|
_showUiError('hard mute', e);
|
||||||
}
|
}
|
||||||
@@ -1651,8 +1654,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_inputMuted = false;
|
_inputMuted = false;
|
||||||
_outputMuted = false;
|
_outputMuted = false;
|
||||||
_hardMute = false;
|
_hardMute = false;
|
||||||
_hardMuteByPermission = false;
|
_hardMuteOwners = const HardMuteOwners();
|
||||||
_hardMuteByTalkPower = false;
|
|
||||||
_inChannel = false;
|
_inChannel = false;
|
||||||
_currentVoiceChannelId = null;
|
_currentVoiceChannelId = null;
|
||||||
_pendingVoiceChannelId = null;
|
_pendingVoiceChannelId = null;
|
||||||
@@ -2083,8 +2085,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||||
final talkPowerEpoch = _connectionEpoch;
|
final talkPowerEpoch = _connectionEpoch;
|
||||||
_hardMuteByTalkPower = true;
|
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
||||||
_hardMute = true;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_connectionEpoch != talkPowerEpoch ||
|
_connectionEpoch != talkPowerEpoch ||
|
||||||
@@ -2097,15 +2099,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||||
final talkPowerEpoch = _connectionEpoch;
|
final talkPowerEpoch = _connectionEpoch;
|
||||||
_hardMuteByTalkPower = false;
|
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
||||||
if (!_hardMuteByPermission) {
|
_hardMute = _hardMuteOwners.effective;
|
||||||
_hardMute = false;
|
if (!_hardMuteOwners.effective) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_connectionEpoch != talkPowerEpoch ||
|
_connectionEpoch != talkPowerEpoch ||
|
||||||
!_serverReachable ||
|
!_serverReachable ||
|
||||||
_hardMuteByTalkPower ||
|
_hardMuteOwners.effective) {
|
||||||
_hardMuteByPermission) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(rust.setHardMute(muted: false));
|
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
|
// exception to the event handler would be noise. The Rust
|
||||||
// engine remains alive and will produce silence until the
|
// engine remains alive and will produce silence until the
|
||||||
// next route change or a manual leave/rejoin.
|
// 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
|
// Worst case the session stays in .playAndRecord until the
|
||||||
// app is backgrounded — at which point iOS reclaims the
|
// app is backgrounded — at which point iOS reclaims the
|
||||||
// session automatically.
|
// 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);
|
playVoicePttHaptic(held);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_pressed) {
|
||||||
|
_pressed = false;
|
||||||
|
widget.onHeldChanged(false);
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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.activate(), completes);
|
||||||
await expectLater(controller.deactivate(), 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