fix(voice): preserve mute owners and release touch ptt

This commit is contained in:
Edison Jwa
2026-06-08 18:00:32 +09:00
parent e4fdf8414a
commit d83539436e
7 changed files with 158 additions and 25 deletions
+26 -25
View File
@@ -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]);
});
}