mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(voice): allow self-mute while push-to-talk is on (#3451)
This commit is contained in:
1 parent
3657438467
commit
f501d34179
27 files changed
+1025
-79
No files matched your search
@@ -206,6 +206,7 @@ const UserAreaInner = observer(
|
|||||||
}, [hasVoiceConnection]);
|
}, [hasVoiceConnection]);
|
||||||
const wrapperClassName = styles.userAreaInnerWrapper;
|
const wrapperClassName = styles.userAreaInnerWrapper;
|
||||||
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
|
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
|
||||||
|
const isSelfMuted = isMuted || muteReason === 'self';
|
||||||
const microphoneState = selectUserAreaMicrophoneState({
|
const microphoneState = selectUserAreaMicrophoneState({
|
||||||
effectiveAudioMuted: isMuted,
|
effectiveAudioMuted: isMuted,
|
||||||
effectiveAudioDeafened: isDeafened,
|
effectiveAudioDeafened: isDeafened,
|
||||||
@@ -214,7 +215,6 @@ const UserAreaInner = observer(
|
|||||||
isPermissionMuted,
|
isPermissionMuted,
|
||||||
muteReason,
|
muteReason,
|
||||||
isPushToTalkEffective,
|
isPushToTalkEffective,
|
||||||
isPushToTalkHeld: Keybind.pushToTalkHeld,
|
|
||||||
isPushToMuteEffective: Keybind.isPushToMuteEffective(),
|
isPushToMuteEffective: Keybind.isPushToMuteEffective(),
|
||||||
isPushToMuteHeld: Keybind.pushToMuteHeld,
|
isPushToMuteHeld: Keybind.pushToMuteHeld,
|
||||||
});
|
});
|
||||||
@@ -224,7 +224,8 @@ const UserAreaInner = observer(
|
|||||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
||||||
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
||||||
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
if (isPushToTalkEffective && !isSelfMuted)
|
||||||
|
return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
||||||
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
||||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||||
})();
|
})();
|
||||||
@@ -232,7 +233,8 @@ const UserAreaInner = observer(
|
|||||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
||||||
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
||||||
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
if (isPushToTalkEffective && !isSelfMuted)
|
||||||
|
return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
||||||
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
||||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectUserAreaMicrophoneState,
|
||||||
|
type UserAreaMicrophoneSignals,
|
||||||
|
} from '@app/features/app/components/layout/UserAreaState';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
function signals(overrides: Partial<UserAreaMicrophoneSignals>): UserAreaMicrophoneSignals {
|
||||||
|
return {
|
||||||
|
effectiveAudioMuted: false,
|
||||||
|
effectiveAudioDeafened: false,
|
||||||
|
isGuildMuted: false,
|
||||||
|
isGuildDeafened: false,
|
||||||
|
isPermissionMuted: false,
|
||||||
|
muteReason: null,
|
||||||
|
isPushToTalkEffective: false,
|
||||||
|
isPushToMuteEffective: false,
|
||||||
|
isPushToMuteHeld: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectUserAreaMicrophoneState', () => {
|
||||||
|
it('shows the idle push to talk gate as unmuted without locking the toggle', () => {
|
||||||
|
expect(
|
||||||
|
selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'voice_push_to_talk'})),
|
||||||
|
).toEqual({effectiveMuted: false, muteToggleLocked: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a self-muted push to talk user as muted', () => {
|
||||||
|
expect(
|
||||||
|
selectUserAreaMicrophoneState(
|
||||||
|
signals({isPushToTalkEffective: true, effectiveAudioMuted: true, muteReason: 'self'}),
|
||||||
|
),
|
||||||
|
).toEqual({effectiveMuted: true, muteToggleLocked: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a push to talk user muted by the failure latch as muted', () => {
|
||||||
|
expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'self'}))).toEqual({
|
||||||
|
effectiveMuted: true,
|
||||||
|
muteToggleLocked: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a self-deafened push to talk user click the toggle', () => {
|
||||||
|
expect(
|
||||||
|
selectUserAreaMicrophoneState(
|
||||||
|
signals({isPushToTalkEffective: true, effectiveAudioMuted: true, effectiveAudioDeafened: true}),
|
||||||
|
),
|
||||||
|
).toEqual({effectiveMuted: true, muteToggleLocked: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the toggle for server and permission mutes in push to talk', () => {
|
||||||
|
for (const overrides of [
|
||||||
|
{isGuildMuted: true, muteReason: 'guild' as const},
|
||||||
|
{isGuildDeafened: true},
|
||||||
|
{isPermissionMuted: true, muteReason: 'permission' as const},
|
||||||
|
]) {
|
||||||
|
expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, ...overrides}))).toEqual({
|
||||||
|
effectiveMuted: true,
|
||||||
|
muteToggleLocked: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps voice activity and push to mute behaviour unchanged', () => {
|
||||||
|
expect(selectUserAreaMicrophoneState(signals({}))).toEqual({effectiveMuted: false, muteToggleLocked: false});
|
||||||
|
expect(selectUserAreaMicrophoneState(signals({effectiveAudioMuted: true, muteReason: 'self'}))).toEqual({
|
||||||
|
effectiveMuted: true,
|
||||||
|
muteToggleLocked: false,
|
||||||
|
});
|
||||||
|
expect(selectUserAreaMicrophoneState(signals({isPushToMuteEffective: true, isPushToMuteHeld: true}))).toEqual({
|
||||||
|
effectiveMuted: true,
|
||||||
|
muteToggleLocked: false,
|
||||||
|
});
|
||||||
|
expect(selectUserAreaMicrophoneState(signals({isGuildMuted: true}))).toEqual({
|
||||||
|
effectiveMuted: true,
|
||||||
|
muteToggleLocked: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,6 @@ export interface UserAreaMicrophoneSignals {
|
|||||||
isPermissionMuted: boolean;
|
isPermissionMuted: boolean;
|
||||||
muteReason: UserAreaMuteReason;
|
muteReason: UserAreaMuteReason;
|
||||||
isPushToTalkEffective: boolean;
|
isPushToTalkEffective: boolean;
|
||||||
isPushToTalkHeld: boolean;
|
|
||||||
isPushToMuteEffective: boolean;
|
isPushToMuteEffective: boolean;
|
||||||
isPushToMuteHeld: boolean;
|
isPushToMuteHeld: boolean;
|
||||||
}
|
}
|
||||||
@@ -32,13 +31,13 @@ export function selectUserAreaMicrophoneState(signals: UserAreaMicrophoneSignals
|
|||||||
if (hardMuted) {
|
if (hardMuted) {
|
||||||
return {
|
return {
|
||||||
effectiveMuted: true,
|
effectiveMuted: true,
|
||||||
muteToggleLocked: muteToggleLocked || signals.isPushToTalkEffective,
|
muteToggleLocked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (signals.isPushToTalkEffective) {
|
if (signals.isPushToTalkEffective) {
|
||||||
return {
|
return {
|
||||||
effectiveMuted: !signals.isPushToTalkHeld,
|
effectiveMuted: signals.effectiveAudioMuted || signals.muteReason === 'self',
|
||||||
muteToggleLocked: true,
|
muteToggleLocked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ class KeybindManager {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
|
this.disposers.push(reactToPushToTalkModeChanges(() => MediaEngine.handlePushToTalkModeChange()));
|
||||||
this.disposers.push(
|
this.disposers.push(
|
||||||
reaction(
|
reaction(
|
||||||
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
|
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe('push-to-talk mode reconcile', () => {
|
|||||||
dispose();
|
dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flips push-to-talk only on settled states and preserves self-mute when it does', () => {
|
it('flips push-to-talk only on settled states and reconciles once per flip', () => {
|
||||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||||
const reconcile = vi.fn();
|
const reconcile = vi.fn();
|
||||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||||
@@ -238,7 +238,7 @@ describe('push-to-talk mode reconcile', () => {
|
|||||||
emit(status);
|
emit(status);
|
||||||
expect(Keybind.isPushToTalkEffective()).toBe(effective);
|
expect(Keybind.isPushToTalkEffective()).toBe(effective);
|
||||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
|
expect(reconcile).toHaveBeenLastCalledWith();
|
||||||
};
|
};
|
||||||
expectFlip(portalStatus('declined'), false);
|
expectFlip(portalStatus('declined'), false);
|
||||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
|
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
|
||||||
@@ -251,8 +251,12 @@ describe('push-to-talk mode reconcile', () => {
|
|||||||
expectFlip(portalStatus('bound'), true);
|
expectFlip(portalStatus('bound'), true);
|
||||||
reconcile.mockClear();
|
reconcile.mockClear();
|
||||||
Keybind.setTransmitMode('voice_activity');
|
Keybind.setTransmitMode('voice_activity');
|
||||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false});
|
expect(Keybind.isPushToTalkEffective()).toBe(false);
|
||||||
|
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reconcile).toHaveBeenLastCalledWith();
|
||||||
Keybind.setTransmitMode('voice_push_to_talk');
|
Keybind.setTransmitMode('voice_push_to_talk');
|
||||||
|
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||||
|
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||||
dispose();
|
dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,14 +3,13 @@
|
|||||||
import Keybind from '@app/features/input/state/InputKeybind';
|
import Keybind from '@app/features/input/state/InputKeybind';
|
||||||
import {compareStructural, reaction} from 'mobx';
|
import {compareStructural, reaction} from 'mobx';
|
||||||
|
|
||||||
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void {
|
export function reactToPushToTalkModeChanges(onChange: () => void): () => void {
|
||||||
return reaction(
|
return reaction(
|
||||||
() => ({
|
() => ({
|
||||||
transmitMode: Keybind.transmitMode,
|
|
||||||
pushToTalk: Keybind.isPushToTalkEffective(),
|
pushToTalk: Keybind.isPushToTalkEffective(),
|
||||||
pushToMute: Keybind.isPushToMuteEffective(),
|
pushToMute: Keybind.isPushToMuteEffective(),
|
||||||
}),
|
}),
|
||||||
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}),
|
() => onChange(),
|
||||||
{equals: compareStructural},
|
{equals: compareStructural},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
const playSound = vi.fn();
|
||||||
|
|
||||||
|
const localVoiceState = {
|
||||||
|
selfMute: false,
|
||||||
|
selfDeaf: false,
|
||||||
|
getSelfMute: () => localVoiceState.selfMute,
|
||||||
|
getSelfDeaf: () => localVoiceState.selfDeaf,
|
||||||
|
updateSelfMute: (muted: boolean) => {
|
||||||
|
localVoiceState.selfMute = muted;
|
||||||
|
},
|
||||||
|
toggleSelfMute: () => {
|
||||||
|
if (localVoiceState.selfDeaf) {
|
||||||
|
localVoiceState.selfDeaf = false;
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localVoiceState.selfMute = !localVoiceState.selfMute;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionMuted = {active: false};
|
||||||
|
const microphoneFailureLatch = {active: false};
|
||||||
|
|
||||||
|
vi.mock('@app/features/input/state/InputKeybind', () => ({
|
||||||
|
default: {isPushToTalkEffective: () => true, pushToTalkHeld: true},
|
||||||
|
}));
|
||||||
|
const updateVoiceStateExplicit = vi.fn();
|
||||||
|
const gatewayVoiceStates: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
guild_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
self_mute: boolean;
|
||||||
|
self_deaf: boolean;
|
||||||
|
self_video: boolean;
|
||||||
|
self_stream: boolean;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
vi.mock('@app/features/gateway/transport/GatewaySessionPool', () => ({
|
||||||
|
default: {foregroundConnection: {socket: {updateVoiceStateExplicit}}},
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/ui/commands/SoundCommands', () => ({
|
||||||
|
playSound: (...args: Array<unknown>) => playSound(...args),
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/permissions/system/commands/MacPermissionsModalCommands', () => ({
|
||||||
|
handleMediaPermissionBlocked: () => undefined,
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/permissions/system/state/MediaPermission', () => ({
|
||||||
|
default: {
|
||||||
|
getMicrophonePermissionState: () => 'granted',
|
||||||
|
isMicrophoneGranted: () => true,
|
||||||
|
isMicrophoneExplicitlyDenied: () => false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/permissions/system/utils/NativePermissions', () => ({
|
||||||
|
ensureNativePermission: async () => 'granted',
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/ui/utils/NativeUtils', () => ({isDesktop: () => false}));
|
||||||
|
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
|
||||||
|
default: {
|
||||||
|
room: null,
|
||||||
|
guildId: '1',
|
||||||
|
channelId: '2',
|
||||||
|
connectionId: 'local',
|
||||||
|
getVoiceStateByConnectionId: (connectionId: string) => gatewayVoiceStates[connectionId] ?? null,
|
||||||
|
isMicrophoneFailureLatched: () => microphoneFailureLatch.active,
|
||||||
|
clearMicrophoneFailureLatch: () => {
|
||||||
|
microphoneFailureLatch.active = false;
|
||||||
|
},
|
||||||
|
getCurrentUserVoiceState: () => null,
|
||||||
|
syncLocalVoiceStateWithServer: () => undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/voice/state/LocalVoiceState', () => ({default: localVoiceState}));
|
||||||
|
vi.mock('@app/features/voice/utils/VoicePermissionUtils', () => ({
|
||||||
|
isVoicePermissionMuteActive: () => permissionMuted.active,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {bulkMuteConnections, getCurrentSelfMute, getSelfMuteForConnection, toggleSelfMute, toggleSelfMuteForConnection} =
|
||||||
|
await import('@app/features/devtools/commands/VoiceStateCommands');
|
||||||
|
const {SoundType} = await import('@app/features/notification/utils/SoundUtils');
|
||||||
|
|
||||||
|
describe('toggleSelfMute with push to talk enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
localVoiceState.selfDeaf = false;
|
||||||
|
permissionMuted.active = false;
|
||||||
|
microphoneFailureLatch.active = false;
|
||||||
|
playSound.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutes and unmutes', async () => {
|
||||||
|
await toggleSelfMute(null);
|
||||||
|
expect(localVoiceState.selfMute).toBe(true);
|
||||||
|
expect(playSound).toHaveBeenLastCalledWith(SoundType.Mute);
|
||||||
|
await toggleSelfMute(null);
|
||||||
|
expect(localVoiceState.selfMute).toBe(false);
|
||||||
|
expect(playSound).toHaveBeenLastCalledWith(SoundType.Unmute);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undeafens like voice activity mode', async () => {
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
localVoiceState.selfDeaf = true;
|
||||||
|
await toggleSelfMute(null);
|
||||||
|
expect(localVoiceState.selfDeaf).toBe(false);
|
||||||
|
expect(localVoiceState.selfMute).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a permission-muted user muted', async () => {
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
permissionMuted.active = true;
|
||||||
|
await toggleSelfMute(null);
|
||||||
|
expect(localVoiceState.selfMute).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('device-scoped self-mute with push to talk enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
localVoiceState.selfDeaf = false;
|
||||||
|
permissionMuted.active = false;
|
||||||
|
microphoneFailureLatch.active = false;
|
||||||
|
playSound.mockClear();
|
||||||
|
updateVoiceStateExplicit.mockClear();
|
||||||
|
const idle = {
|
||||||
|
guild_id: '1',
|
||||||
|
channel_id: '2',
|
||||||
|
self_mute: true,
|
||||||
|
self_deaf: false,
|
||||||
|
self_video: false,
|
||||||
|
self_stream: false,
|
||||||
|
};
|
||||||
|
gatewayVoiceStates.local = {...idle};
|
||||||
|
gatewayVoiceStates.other = {...idle, self_mute: false};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the local device from local self-mute instead of the push-to-talk gateway value', () => {
|
||||||
|
expect(getSelfMuteForConnection('local')).toBe(false);
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
gatewayVoiceStates.local.self_mute = false;
|
||||||
|
expect(getSelfMuteForConnection('local')).toBe(true);
|
||||||
|
expect(getSelfMuteForConnection('other')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles the local device through local self-mute without a direct gateway write', async () => {
|
||||||
|
await toggleSelfMuteForConnection('local');
|
||||||
|
expect(localVoiceState.selfMute).toBe(true);
|
||||||
|
expect(playSound).toHaveBeenLastCalledWith(SoundType.Mute);
|
||||||
|
await toggleSelfMuteForConnection('local');
|
||||||
|
expect(localVoiceState.selfMute).toBe(false);
|
||||||
|
expect(playSound).toHaveBeenLastCalledWith(SoundType.Unmute);
|
||||||
|
expect(updateVoiceStateExplicit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles another device through the gateway without touching local self-mute', async () => {
|
||||||
|
await toggleSelfMuteForConnection('other');
|
||||||
|
expect(updateVoiceStateExplicit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateVoiceStateExplicit).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({connection_id: 'other', self_mute: true}),
|
||||||
|
);
|
||||||
|
expect(localVoiceState.selfMute).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a latched microphone failure as self-muted and unmutes it on toggle', async () => {
|
||||||
|
microphoneFailureLatch.active = true;
|
||||||
|
expect(getCurrentSelfMute()).toBe(true);
|
||||||
|
expect(getSelfMuteForConnection('local')).toBe(true);
|
||||||
|
await toggleSelfMuteForConnection('local');
|
||||||
|
expect(microphoneFailureLatch.active).toBe(false);
|
||||||
|
expect(getCurrentSelfMute()).toBe(false);
|
||||||
|
expect(playSound).toHaveBeenLastCalledWith(SoundType.Unmute);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk mutes the local device through local self-mute and other devices through the gateway', async () => {
|
||||||
|
await bulkMuteConnections(['local', 'other'], true);
|
||||||
|
expect(localVoiceState.selfMute).toBe(true);
|
||||||
|
expect(updateVoiceStateExplicit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateVoiceStateExplicit).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({connection_id: 'other', self_mute: true}),
|
||||||
|
);
|
||||||
|
updateVoiceStateExplicit.mockClear();
|
||||||
|
gatewayVoiceStates.other.self_mute = true;
|
||||||
|
microphoneFailureLatch.active = true;
|
||||||
|
await bulkMuteConnections(['local', 'other'], false);
|
||||||
|
expect(localVoiceState.selfMute).toBe(false);
|
||||||
|
expect(microphoneFailureLatch.active).toBe(false);
|
||||||
|
expect(updateVoiceStateExplicit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(updateVoiceStateExplicit).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({connection_id: 'other', self_mute: false}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
|
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
|
||||||
import Keybind from '@app/features/input/state/InputKeybind';
|
|
||||||
import {SoundType} from '@app/features/notification/utils/SoundUtils';
|
import {SoundType} from '@app/features/notification/utils/SoundUtils';
|
||||||
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||||
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
|
import MediaPermission from '@app/features/permissions/system/state/MediaPermission';
|
||||||
@@ -119,14 +118,10 @@ const requestMicrophoneDirectly = async (): Promise<boolean> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function toggleSelfMute(_guildId: string | null = null): Promise<void> {
|
export async function toggleSelfMute(_guildId: string | null = null): Promise<void> {
|
||||||
if (Keybind.isPushToTalkEffective()) {
|
|
||||||
logger.debug('Ignoring self-mute toggle: push-to-talk controls the microphone');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const room = MediaEngine.room;
|
const room = MediaEngine.room;
|
||||||
const connectedChannelId = MediaEngine.channelId;
|
const connectedChannelId = MediaEngine.channelId;
|
||||||
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
|
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
|
||||||
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
|
const currentMute = getCurrentSelfMute();
|
||||||
const currentDeaf = LocalVoiceState.getSelfDeaf();
|
const currentDeaf = LocalVoiceState.getSelfDeaf();
|
||||||
const willUndeafen = currentDeaf;
|
const willUndeafen = currentDeaf;
|
||||||
const willUnmute = currentMute;
|
const willUnmute = currentMute;
|
||||||
@@ -238,7 +233,20 @@ const updateConnectionsProperty = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getCurrentSelfMute(): boolean {
|
||||||
|
return LocalVoiceState.getSelfMute() || MediaEngine.isMicrophoneFailureLatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelfMuteForConnection(connectionId: string): boolean {
|
||||||
|
if (connectionId === MediaEngine.connectionId) return getCurrentSelfMute();
|
||||||
|
return MediaEngine.getVoiceStateByConnectionId(connectionId)?.self_mute ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleSelfMuteForConnection(connectionId: string): Promise<void> {
|
export async function toggleSelfMuteForConnection(connectionId: string): Promise<void> {
|
||||||
|
if (connectionId === MediaEngine.connectionId) {
|
||||||
|
await toggleSelfMute(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const voiceState = MediaEngine.getVoiceStateByConnectionId(connectionId);
|
const voiceState = MediaEngine.getVoiceStateByConnectionId(connectionId);
|
||||||
if (!voiceState) return;
|
if (!voiceState) return;
|
||||||
const target = !voiceState.self_mute;
|
const target = !voiceState.self_mute;
|
||||||
@@ -265,7 +273,16 @@ export async function turnOffStreamForConnection(connectionId: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkMuteConnections(connectionIds: Array<string>, mute: boolean = true): Promise<void> {
|
export async function bulkMuteConnections(connectionIds: Array<string>, mute: boolean = true): Promise<void> {
|
||||||
await updateConnectionsProperty(connectionIds, 'self_mute', mute);
|
const currentConnectionId = MediaEngine.connectionId;
|
||||||
|
if (currentConnectionId && connectionIds.includes(currentConnectionId)) {
|
||||||
|
if (!mute) MediaEngine.clearMicrophoneFailureLatch();
|
||||||
|
LocalVoiceState.updateSelfMute(mute);
|
||||||
|
}
|
||||||
|
await updateConnectionsProperty(
|
||||||
|
connectionIds.filter((connectionId) => connectionId !== currentConnectionId),
|
||||||
|
'self_mute',
|
||||||
|
mute,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeafenConnections(connectionIds: Array<string>, deafen: boolean = true): Promise<void> {
|
export async function bulkDeafenConnections(connectionIds: Array<string>, deafen: boolean = true): Promise<void> {
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
const useConnectionSpecificControls =
|
const useConnectionSpecificControls =
|
||||||
connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId);
|
connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId);
|
||||||
if (useConnectionSpecificControls) {
|
if (useConnectionSpecificControls) {
|
||||||
const isSelfMuted = connectionVoiceState?.self_mute ?? false;
|
const isSelfMuted = VoiceStateCommands.getSelfMuteForConnection(connectionId);
|
||||||
const isSelfDeafened = connectionVoiceState?.self_deaf ?? false;
|
const isSelfDeafened = connectionVoiceState?.self_deaf ?? false;
|
||||||
const isCameraOn = connectionVoiceState?.self_video ?? false;
|
const isCameraOn = connectionVoiceState?.self_video ?? false;
|
||||||
const isStreaming = connectionVoiceState?.self_stream ?? false;
|
const isStreaming = connectionVoiceState?.self_stream ?? false;
|
||||||
@@ -726,7 +726,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
}
|
}
|
||||||
if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId));
|
if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId));
|
||||||
} else if (currentConnectionId && canUseCurrentConnectionAsRoot) {
|
} else if (currentConnectionId && canUseCurrentConnectionAsRoot) {
|
||||||
const isSelfMuted = currentUserVoiceState?.self_mute ?? false;
|
const isSelfMuted = VoiceStateCommands.getCurrentSelfMute();
|
||||||
const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false;
|
const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false;
|
||||||
const isCameraOn = currentConnectionVoiceState?.self_video ?? false;
|
const isCameraOn = currentConnectionVoiceState?.self_video ?? false;
|
||||||
const isStreaming = currentConnectionVoiceState?.self_stream ?? false;
|
const isStreaming = currentConnectionVoiceState?.self_stream ?? false;
|
||||||
@@ -909,7 +909,9 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
const bulkActions: Array<MenuItemType> = [];
|
const bulkActions: Array<MenuItemType> = [];
|
||||||
const connectionIds = userVoiceStates.map((u) => u.connectionId);
|
const connectionIds = userVoiceStates.map((u) => u.connectionId);
|
||||||
if (isCurrentUser && isCurrentUserConnectedToVoice) {
|
if (isCurrentUser && isCurrentUserConnectedToVoice) {
|
||||||
const allMuted = userVoiceStates.every(({voiceState}) => voiceState.self_mute);
|
const allMuted = userVoiceStates.every(({connectionId}) =>
|
||||||
|
VoiceStateCommands.getSelfMuteForConnection(connectionId),
|
||||||
|
);
|
||||||
bulkActions.push({
|
bulkActions.push({
|
||||||
icon: (
|
icon: (
|
||||||
<SelfMuteIcon
|
<SelfMuteIcon
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ interface SelfMuteMenuItemProps {
|
|||||||
export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer(
|
export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer(
|
||||||
({connectionId, isDeviceSpecific = false, label}) => {
|
({connectionId, isDeviceSpecific = false, label}) => {
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
const voiceState = connectionId
|
const isSelfMuted =
|
||||||
? MediaEngine.getVoiceStateByConnectionId(connectionId)
|
isDeviceSpecific && connectionId
|
||||||
: MediaEngine.getCurrentUserVoiceState();
|
? VoiceStateCommands.getSelfMuteForConnection(connectionId)
|
||||||
const isSelfMuted = voiceState?.self_mute ?? false;
|
: VoiceStateCommands.getCurrentSelfMute();
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (isDeviceSpecific && connectionId) {
|
if (isDeviceSpecific && connectionId) {
|
||||||
VoiceStateCommands.toggleSelfMuteForConnection(connectionId);
|
VoiceStateCommands.toggleSelfMuteForConnection(connectionId);
|
||||||
|
|||||||
@@ -551,6 +551,7 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
|
|||||||
isGuildDeafened,
|
isGuildDeafened,
|
||||||
isPermissionMuted,
|
isPermissionMuted,
|
||||||
isPushToTalkEffective,
|
isPushToTalkEffective,
|
||||||
|
isSelfMuted: effectiveAudioState.effectiveMute || muteReason === 'self',
|
||||||
effectiveMuted,
|
effectiveMuted,
|
||||||
canStream,
|
canStream,
|
||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
transitionVoiceControlBarState,
|
||||||
|
type VoiceControlBarSignals,
|
||||||
|
} from '@app/features/voice/components/VoiceControlBarStateMachine';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
function signals(overrides: Partial<VoiceControlBarSignals>): VoiceControlBarSignals {
|
||||||
|
return {
|
||||||
|
isDeafened: false,
|
||||||
|
isGuildMuted: false,
|
||||||
|
isGuildDeafened: false,
|
||||||
|
isPermissionMuted: false,
|
||||||
|
isPushToTalkEffective: false,
|
||||||
|
isSelfMuted: false,
|
||||||
|
effectiveMuted: false,
|
||||||
|
canStream: true,
|
||||||
|
isCameraEnabled: false,
|
||||||
|
isCameraLimitReached: false,
|
||||||
|
isCameraUserCapReached: false,
|
||||||
|
isScreenShareEnabled: false,
|
||||||
|
screenShareDisabled: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('voice control bar mute button in push to talk', () => {
|
||||||
|
it('enables the toggle and shows the idle gate as unmuted with the hold hint', () => {
|
||||||
|
expect(transitionVoiceControlBarState(signals({isPushToTalkEffective: true, effectiveMuted: true})).mute).toEqual({
|
||||||
|
disabled: false,
|
||||||
|
pressed: false,
|
||||||
|
label: 'pushToTalkHoldHint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers unmute when self-muted', () => {
|
||||||
|
expect(
|
||||||
|
transitionVoiceControlBarState(signals({isPushToTalkEffective: true, isSelfMuted: true, effectiveMuted: true}))
|
||||||
|
.mute,
|
||||||
|
).toEqual({disabled: false, pressed: true, label: 'unmute'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps server and permission mutes locked', () => {
|
||||||
|
expect(
|
||||||
|
transitionVoiceControlBarState(signals({isPushToTalkEffective: true, isGuildMuted: true, effectiveMuted: true}))
|
||||||
|
.mute,
|
||||||
|
).toEqual({disabled: true, pressed: true, label: 'moderatorMuted'});
|
||||||
|
expect(
|
||||||
|
transitionVoiceControlBarState(
|
||||||
|
signals({isPushToTalkEffective: true, isPermissionMuted: true, effectiveMuted: true}),
|
||||||
|
).mute,
|
||||||
|
).toEqual({disabled: true, pressed: true, label: 'permissionMuted'});
|
||||||
|
expect(
|
||||||
|
transitionVoiceControlBarState(
|
||||||
|
signals({isPushToTalkEffective: true, isGuildDeafened: true, effectiveMuted: true}),
|
||||||
|
).mute,
|
||||||
|
).toEqual({disabled: true, pressed: true, label: 'moderatorDeafened'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps voice activity labels unchanged', () => {
|
||||||
|
expect(transitionVoiceControlBarState(signals({})).mute).toEqual({disabled: false, pressed: false, label: 'mute'});
|
||||||
|
expect(transitionVoiceControlBarState(signals({isSelfMuted: true, effectiveMuted: true})).mute).toEqual({
|
||||||
|
disabled: false,
|
||||||
|
pressed: true,
|
||||||
|
label: 'unmute',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ export interface VoiceControlBarSignals {
|
|||||||
isGuildDeafened: boolean;
|
isGuildDeafened: boolean;
|
||||||
isPermissionMuted: boolean;
|
isPermissionMuted: boolean;
|
||||||
isPushToTalkEffective: boolean;
|
isPushToTalkEffective: boolean;
|
||||||
|
isSelfMuted: boolean;
|
||||||
effectiveMuted: boolean;
|
effectiveMuted: boolean;
|
||||||
canStream: boolean;
|
canStream: boolean;
|
||||||
isCameraEnabled: boolean;
|
isCameraEnabled: boolean;
|
||||||
@@ -54,6 +55,7 @@ const DEFAULT_SIGNALS: VoiceControlBarSignals = {
|
|||||||
isGuildDeafened: false,
|
isGuildDeafened: false,
|
||||||
isPermissionMuted: false,
|
isPermissionMuted: false,
|
||||||
isPushToTalkEffective: false,
|
isPushToTalkEffective: false,
|
||||||
|
isSelfMuted: false,
|
||||||
effectiveMuted: false,
|
effectiveMuted: false,
|
||||||
canStream: true,
|
canStream: true,
|
||||||
isCameraEnabled: false,
|
isCameraEnabled: false,
|
||||||
@@ -74,7 +76,7 @@ function selectMuteLabel(signals: VoiceControlBarSignals): VoiceControlBarMuteLa
|
|||||||
if (signals.isGuildDeafened) return 'moderatorDeafened';
|
if (signals.isGuildDeafened) return 'moderatorDeafened';
|
||||||
if (signals.isGuildMuted) return 'moderatorMuted';
|
if (signals.isGuildMuted) return 'moderatorMuted';
|
||||||
if (signals.isPermissionMuted) return 'permissionMuted';
|
if (signals.isPermissionMuted) return 'permissionMuted';
|
||||||
if (signals.isPushToTalkEffective) return 'pushToTalkHoldHint';
|
if (signals.isPushToTalkEffective && !signals.isSelfMuted) return 'pushToTalkHoldHint';
|
||||||
return signals.effectiveMuted ? 'unmute' : 'mute';
|
return signals.effectiveMuted ? 'unmute' : 'mute';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +99,10 @@ function selectScreenShareLabel(signals: VoiceControlBarSignals): VoiceControlBa
|
|||||||
|
|
||||||
function selectVoiceControlBarState(signals: VoiceControlBarSignals): VoiceControlBarState {
|
function selectVoiceControlBarState(signals: VoiceControlBarSignals): VoiceControlBarState {
|
||||||
const isMicLocked = signals.isGuildMuted || signals.isGuildDeafened || signals.isPermissionMuted;
|
const isMicLocked = signals.isGuildMuted || signals.isGuildDeafened || signals.isPermissionMuted;
|
||||||
const isMuteToggleLocked = isMicLocked || signals.isPushToTalkEffective;
|
|
||||||
return {
|
return {
|
||||||
mute: {
|
mute: {
|
||||||
disabled: isMuteToggleLocked,
|
disabled: isMicLocked,
|
||||||
pressed: signals.effectiveMuted,
|
pressed: signals.isPushToTalkEffective ? isMicLocked || signals.isSelfMuted : signals.effectiveMuted,
|
||||||
label: selectMuteLabel(signals),
|
label: selectMuteLabel(signals),
|
||||||
},
|
},
|
||||||
deafen: {
|
deafen: {
|
||||||
|
|||||||
+2
-2
@@ -104,7 +104,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto
|
|||||||
useMediaEngineVersion();
|
useMediaEngineVersion();
|
||||||
const callHeaderState = useCallHeaderState(channel);
|
const callHeaderState = useCallHeaderState(channel);
|
||||||
const voiceState = MediaEngine.getCurrentUserVoiceState(channel.guildId ?? null);
|
const voiceState = MediaEngine.getCurrentUserVoiceState(channel.guildId ?? null);
|
||||||
const localSelfMute = LocalVoiceState.getSelfMute();
|
const localSelfMute = VoiceStateCommands.getCurrentSelfMute();
|
||||||
const localSelfDeaf = LocalVoiceState.getSelfDeaf();
|
const localSelfDeaf = LocalVoiceState.getSelfDeaf();
|
||||||
const localSelfVideo = LocalVoiceState.getSelfVideo();
|
const localSelfVideo = LocalVoiceState.getSelfVideo();
|
||||||
const currentLatency = MediaEngine.currentLatency;
|
const currentLatency = MediaEngine.currentLatency;
|
||||||
@@ -112,7 +112,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto
|
|||||||
const voiceServerEndpoint = MediaEngine.voiceServerEndpoint;
|
const voiceServerEndpoint = MediaEngine.voiceServerEndpoint;
|
||||||
const connectionId = MediaEngine.connectionId;
|
const connectionId = MediaEngine.connectionId;
|
||||||
const isConnected = MediaEngine.connected && MediaEngine.channelId === channel.id;
|
const isConnected = MediaEngine.connected && MediaEngine.channelId === channel.id;
|
||||||
const isMuted = voiceState ? voiceState.self_mute : localSelfMute;
|
const isMuted = localSelfMute;
|
||||||
const isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf;
|
const isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf;
|
||||||
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
||||||
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
|
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({
|
|||||||
const connectedGuildId = MediaEngine.guildId;
|
const connectedGuildId = MediaEngine.guildId;
|
||||||
const connectedChannelId = MediaEngine.channelId;
|
const connectedChannelId = MediaEngine.channelId;
|
||||||
const voiceState = MediaEngine.getCurrentUserVoiceState(connectedGuildId);
|
const voiceState = MediaEngine.getCurrentUserVoiceState(connectedGuildId);
|
||||||
const localSelfMute = LocalVoiceState.getSelfMute();
|
const localSelfMute = VoiceStateCommands.getCurrentSelfMute();
|
||||||
const localSelfDeaf = LocalVoiceState.getSelfDeaf();
|
const localSelfDeaf = LocalVoiceState.getSelfDeaf();
|
||||||
const localSelfVideo = LocalVoiceState.getSelfVideo();
|
const localSelfVideo = LocalVoiceState.getSelfVideo();
|
||||||
const currentLatency = MediaEngine.currentLatency;
|
const currentLatency = MediaEngine.currentLatency;
|
||||||
@@ -96,8 +96,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({
|
|||||||
const isPermissionMuted = isVoicePermissionMuteActive(voiceState, guild.id, channel.id);
|
const isPermissionMuted = isVoicePermissionMuteActive(voiceState, guild.id, channel.id);
|
||||||
const isGuildMuted = voiceState?.mute ?? false;
|
const isGuildMuted = voiceState?.mute ?? false;
|
||||||
const isGuildDeafened = voiceState?.deaf ?? false;
|
const isGuildDeafened = voiceState?.deaf ?? false;
|
||||||
const isMuted =
|
const isMuted = localSelfMute || isGuildMuted || isGuildDeafened || isPermissionMuted;
|
||||||
(voiceState ? voiceState.self_mute : localSelfMute) || isGuildMuted || isGuildDeafened || isPermissionMuted;
|
|
||||||
const isDeafened = (voiceState ? voiceState.self_deaf : localSelfDeaf) || isGuildDeafened;
|
const isDeafened = (voiceState ? voiceState.self_deaf : localSelfDeaf) || isGuildDeafened;
|
||||||
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
||||||
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
|
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ import voiceEngineV2AppScreenShareExecutionAdapter, {
|
|||||||
type ScreenShareReconnectSnapshot,
|
type ScreenShareReconnectSnapshot,
|
||||||
shouldRestoreScreenShareAfterReconnect,
|
shouldRestoreScreenShareAfterReconnect,
|
||||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
||||||
import {selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
import {selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||||
import {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge';
|
import {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge';
|
||||||
import {VoiceEngineV2AppStatsHostAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter';
|
import {VoiceEngineV2AppStatsHostAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter';
|
||||||
import VoiceEngineV2AppSubscriptionAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppSubscriptionAdapter';
|
import VoiceEngineV2AppSubscriptionAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppSubscriptionAdapter';
|
||||||
@@ -1503,7 +1503,7 @@ class MediaEngineFacade extends Store {
|
|||||||
|
|
||||||
private getEffectiveSelfMuteForVoiceStatePayload(): boolean {
|
private getEffectiveSelfMuteForVoiceStatePayload(): boolean {
|
||||||
this.syncVoiceEngineV2AudioControlsFromAppState();
|
this.syncVoiceEngineV2AudioControlsFromAppState();
|
||||||
return selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot);
|
return selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelPendingServerDisconnect(): void {
|
private cancelPendingServerDisconnect(): void {
|
||||||
@@ -1534,7 +1534,10 @@ class MediaEngineFacade extends Store {
|
|||||||
raw.connection_id,
|
raw.connection_id,
|
||||||
);
|
);
|
||||||
LocalVoiceState.seedConnectionState(raw.connection_id, {
|
LocalVoiceState.seedConnectionState(raw.connection_id, {
|
||||||
selfMute: currentVoiceState?.self_mute ?? LocalVoiceState.getSelfMute(),
|
selfMute:
|
||||||
|
this.isPushToTalkActive() || this.isPushToMuteActive()
|
||||||
|
? LocalVoiceState.getSelfMute()
|
||||||
|
: (currentVoiceState?.self_mute ?? LocalVoiceState.getSelfMute()),
|
||||||
selfDeaf: currentVoiceState?.self_deaf ?? LocalVoiceState.getSelfDeaf(),
|
selfDeaf: currentVoiceState?.self_deaf ?? LocalVoiceState.getSelfDeaf(),
|
||||||
selfVideo: currentVoiceState?.self_video ?? LocalVoiceState.getSelfVideo(),
|
selfVideo: currentVoiceState?.self_video ?? LocalVoiceState.getSelfVideo(),
|
||||||
selfStream: currentVoiceState?.self_stream ?? LocalVoiceState.getSelfStream(),
|
selfStream: currentVoiceState?.self_stream ?? LocalVoiceState.getSelfStream(),
|
||||||
@@ -2041,7 +2044,10 @@ class MediaEngineFacade extends Store {
|
|||||||
selfStream: payload.self_stream,
|
selfStream: payload.self_stream,
|
||||||
});
|
});
|
||||||
if (partial?.viewer_stream_keys !== undefined) {
|
if (partial?.viewer_stream_keys !== undefined) {
|
||||||
syncVoiceStateToServer(guildId, channelId, connectionId, {viewer_stream_keys: payload.viewer_stream_keys});
|
syncVoiceStateToServer(guildId, channelId, connectionId, {
|
||||||
|
self_mute: payload.self_mute,
|
||||||
|
viewer_stream_keys: payload.viewer_stream_keys,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2302,12 +2308,8 @@ class MediaEngineFacade extends Store {
|
|||||||
this.syncVoiceEngineV2AudioControlsFromAppState();
|
this.syncVoiceEngineV2AudioControlsFromAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePushToTalkModeChange(options: {preserveSelfMute?: boolean} = {}): void {
|
handlePushToTalkModeChange(): void {
|
||||||
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(
|
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(this.room, () => this.getCurrentUserVoiceState());
|
||||||
this.room,
|
|
||||||
() => this.getCurrentUserVoiceState(),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
this.syncVoiceEngineV2AudioControlsFromAppState();
|
this.syncVoiceEngineV2AudioControlsFromAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2387,11 +2389,9 @@ class MediaEngineFacade extends Store {
|
|||||||
requestMicrophoneRefresh: (targetRoom, request) =>
|
requestMicrophoneRefresh: (targetRoom, request) =>
|
||||||
voiceEngineV2AppMediaExecutionAdapter.requestMicrophoneRefresh(targetRoom, request),
|
voiceEngineV2AppMediaExecutionAdapter.requestMicrophoneRefresh(targetRoom, request),
|
||||||
configureVoiceInput: (targetRoom) => voiceEngineV2AppMediaExecutionAdapter.configureVoiceInput(targetRoom),
|
configureVoiceInput: (targetRoom) => voiceEngineV2AppMediaExecutionAdapter.configureVoiceInput(targetRoom),
|
||||||
handleInputKeybindChange: (targetRoom, options) =>
|
handleInputKeybindChange: (targetRoom) =>
|
||||||
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(
|
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(targetRoom, () =>
|
||||||
targetRoom,
|
this.getCurrentUserVoiceState(),
|
||||||
() => this.getCurrentUserVoiceState(),
|
|
||||||
options,
|
|
||||||
),
|
),
|
||||||
applyAllLocalAudioPreferences: () => voiceEngineV2AppMediaExecutionAdapter.applyAllLocalAudioPreferences(room),
|
applyAllLocalAudioPreferences: () => voiceEngineV2AppMediaExecutionAdapter.applyAllLocalAudioPreferences(room),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer';
|
||||||
|
import {
|
||||||
|
availableVoiceEngineV2Capabilities,
|
||||||
|
createVoiceEngineV2InitialSnapshot,
|
||||||
|
type VoiceEngineV2Snapshot,
|
||||||
|
} from '@fluxer/voice_engine_v2/src/core/state';
|
||||||
|
import type {VoiceEngineV2AudioControlsPatch} from '@fluxer/voice_engine_v2/src/protocol/types';
|
||||||
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
const updateVoiceStateExplicit = vi.fn();
|
||||||
|
const engine: {snapshot: VoiceEngineV2Snapshot | null} = {snapshot: null};
|
||||||
|
|
||||||
|
vi.mock('@lingui/core/macro', () => {
|
||||||
|
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||||
|
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@lingui/react/macro', () => ({
|
||||||
|
Trans: () => null,
|
||||||
|
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/gateway/transport/GatewaySessionPool', () => ({
|
||||||
|
default: {foregroundConnection: {socket: {updateVoiceStateExplicit}}},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/VoiceMediaEngineBridge', () => ({
|
||||||
|
getAllVoiceStatesInChannelFromMediaEngine: () => ({}),
|
||||||
|
getVoiceConnectionContextFromMediaEngine: () => ({connectionId: 'local'}),
|
||||||
|
getVoiceEngineV2SnapshotFromMediaEngine: () => engine.snapshot,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/utils/VoicePermissionUtils', () => ({
|
||||||
|
applyVoiceSpeakPermissionToSelfMute: (_guildId: string | null, _channelId: string, selfMute: boolean) => selfMute,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/components/alerts/VoiceChannelFullModal', () => ({VoiceChannelFullModal: () => null}));
|
||||||
|
vi.mock('@app/features/voice/components/alerts/VoiceConnectionConfirmModal', () => ({
|
||||||
|
VoiceConnectionConfirmModal: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock('@app/features/voice/components/alerts/VoiceP2pConsentModal', () => ({VoiceP2pConsentModal: () => null}));
|
||||||
|
|
||||||
|
const {syncVoiceStateToServer} = await import('@app/features/voice/engine/VoiceChannelConnector');
|
||||||
|
|
||||||
|
function snapshotWith(controls: VoiceEngineV2AudioControlsPatch): VoiceEngineV2Snapshot {
|
||||||
|
return transitionVoiceEngineV2(createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities()), {
|
||||||
|
type: 'audioControls.changed',
|
||||||
|
controls,
|
||||||
|
}).snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastSelfMute(): boolean {
|
||||||
|
const [payload] = updateVoiceStateExplicit.mock.lastCall as [{self_mute: boolean}];
|
||||||
|
return payload.self_mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('direct gateway voice state sync with push to talk', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
updateVoiceStateExplicit.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports self_mute while the push to talk key is up on a viewer stream key sync', () => {
|
||||||
|
engine.snapshot = snapshotWith({mode: 'pushToTalk', pushToTalkActive: false, locallyMuted: false});
|
||||||
|
syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []});
|
||||||
|
expect(lastSelfMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unmuted while the push to talk key is held', () => {
|
||||||
|
engine.snapshot = snapshotWith({mode: 'pushToTalk', pushToTalkActive: true, locallyMuted: false});
|
||||||
|
syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []});
|
||||||
|
expect(lastSelfMute()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports self-mute over a held push to talk key', () => {
|
||||||
|
engine.snapshot = snapshotWith({mode: 'pushToTalk', pushToTalkActive: true, locallyMuted: true});
|
||||||
|
syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []});
|
||||||
|
expect(lastSelfMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps voice activity unmuted and prefers an explicit self_mute', () => {
|
||||||
|
engine.snapshot = snapshotWith({mode: 'voiceActivity', locallyMuted: false});
|
||||||
|
syncVoiceStateToServer('1', '2', 'local', {viewer_stream_keys: []});
|
||||||
|
expect(lastSelfMute()).toBe(false);
|
||||||
|
syncVoiceStateToServer('1', '2', 'local', {self_mute: true});
|
||||||
|
expect(lastSelfMute()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type VoiceMediaEngineVoiceState,
|
type VoiceMediaEngineVoiceState,
|
||||||
} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
||||||
import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes';
|
import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes';
|
||||||
import {selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
import {selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||||
import {isChannelP2p} from '@app/features/voice/state/ChannelP2pStatus';
|
import {isChannelP2p} from '@app/features/voice/state/ChannelP2pStatus';
|
||||||
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||||
import VoiceP2pConsent from '@app/features/voice/state/VoiceP2pConsent';
|
import VoiceP2pConsent from '@app/features/voice/state/VoiceP2pConsent';
|
||||||
@@ -180,7 +180,7 @@ export function sendVoiceStateConnect(
|
|||||||
LocalVoiceState.ensurePermissionMute();
|
LocalVoiceState.ensurePermissionMute();
|
||||||
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
|
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
|
||||||
const effectiveSelfMute = snapshot
|
const effectiveSelfMute = snapshot
|
||||||
? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot)
|
? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot)
|
||||||
: LocalVoiceState.getSelfMute();
|
: LocalVoiceState.getSelfMute();
|
||||||
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, effectiveSelfMute);
|
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, effectiveSelfMute);
|
||||||
const connectionId = getVoiceConnectionContextFromMediaEngine()?.connectionId ?? null;
|
const connectionId = getVoiceConnectionContextFromMediaEngine()?.connectionId ?? null;
|
||||||
@@ -227,7 +227,7 @@ export function syncVoiceStateToServer(
|
|||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
|
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
|
||||||
const effectiveSelfMute = snapshot
|
const effectiveSelfMute = snapshot
|
||||||
? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot)
|
? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot)
|
||||||
: LocalVoiceState.getSelfMute();
|
: LocalVoiceState.getSelfMute();
|
||||||
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, partial?.self_mute ?? effectiveSelfMute);
|
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, partial?.self_mute ?? effectiveSelfMute);
|
||||||
socket.updateVoiceStateExplicit({
|
socket.updateVoiceStateExplicit({
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
type LocalSpeakingOverrideInput,
|
||||||
|
resolveLocalSpeakingOverrideState,
|
||||||
|
} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
function input(overrides: Partial<LocalSpeakingOverrideInput>): LocalSpeakingOverrideInput {
|
||||||
|
return {
|
||||||
|
pushToTalkActive: true,
|
||||||
|
pushToMuteActive: false,
|
||||||
|
pushToMuteHeld: false,
|
||||||
|
selfDeaf: false,
|
||||||
|
effectiveSelfMute: false,
|
||||||
|
hasMicrophonePublication: true,
|
||||||
|
microphonePublicationMuted: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveLocalSpeakingOverrideState in push to talk', () => {
|
||||||
|
it('never lights the speaking indicator for a self-muted user holding the key', () => {
|
||||||
|
expect(resolveLocalSpeakingOverrideState(input({effectiveSelfMute: true}))).toBe(false);
|
||||||
|
expect(resolveLocalSpeakingOverrideState(input({effectiveSelfMute: true, microphonePublicationMuted: true}))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights the speaking indicator for an unmuted user holding the key', () => {
|
||||||
|
expect(resolveLocalSpeakingOverrideState(input({}))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import type {Room} from 'livekit-client';
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@lingui/core/macro', () => {
|
||||||
|
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||||
|
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@lingui/react/macro', () => ({
|
||||||
|
Trans: () => null,
|
||||||
|
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: {}}));
|
||||||
|
|
||||||
|
const {default: Keybind} = await import('@app/features/input/state/InputKeybind');
|
||||||
|
const {isVoiceActivityGateEnabled} = await import('@app/features/voice/utils/VoiceInputProcessor');
|
||||||
|
const {bindVoiceEngineV2AppAudioPreferencesSync} = await import(
|
||||||
|
'@app/features/voice/engine/v2/VoiceEngineV2AppAudioPreferencesSyncBinding'
|
||||||
|
);
|
||||||
|
|
||||||
|
function createAdapter() {
|
||||||
|
return {
|
||||||
|
requestMicrophoneRefresh: vi.fn(async () => {}),
|
||||||
|
configureVoiceInput: vi.fn(),
|
||||||
|
handleInputKeybindChange: vi.fn(),
|
||||||
|
applyAllLocalAudioPreferences: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('voice engine v2 audio preferences sync binding', () => {
|
||||||
|
let dispose: (() => void) | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dispose?.();
|
||||||
|
dispose = null;
|
||||||
|
Keybind.setTransmitMode('voice_activity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconfigures voice input when transmit mode flips without a push-to-mute keybind', () => {
|
||||||
|
Keybind.setTransmitMode('voice_activity');
|
||||||
|
expect(Keybind.isPushToMuteEffective()).toBe(false);
|
||||||
|
const gateBefore = isVoiceActivityGateEnabled();
|
||||||
|
const adapter = createAdapter();
|
||||||
|
dispose = bindVoiceEngineV2AppAudioPreferencesSync({} as Room, adapter, {warn: vi.fn()});
|
||||||
|
|
||||||
|
Keybind.setTransmitMode('voice_push_to_talk');
|
||||||
|
|
||||||
|
expect(isVoiceActivityGateEnabled()).toBe(false);
|
||||||
|
expect(gateBefore).not.toBe(isVoiceActivityGateEnabled());
|
||||||
|
expect(adapter.configureVoiceInput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(adapter.handleInputKeybindChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
Keybind.setTransmitMode('voice_activity');
|
||||||
|
|
||||||
|
expect(isVoiceActivityGateEnabled()).toBe(gateBefore);
|
||||||
|
expect(adapter.configureVoiceInput).toHaveBeenCalledTimes(2);
|
||||||
|
expect(adapter.handleInputKeybindChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-4
@@ -23,14 +23,13 @@ interface VoiceEngineV2AppAudioPreferencesSnapshot {
|
|||||||
readonly participantMutes: Readonly<Record<string, boolean>>;
|
readonly participantMutes: Readonly<Record<string, boolean>>;
|
||||||
readonly connectionVolumes: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
readonly connectionVolumes: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
||||||
readonly streamAudioRevision: number;
|
readonly streamAudioRevision: number;
|
||||||
readonly transmitMode: typeof Keybind.transmitMode;
|
|
||||||
readonly pushToMuteActive: boolean;
|
readonly pushToMuteActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceEngineV2AppAudioPreferencesMediaAdapter {
|
export interface VoiceEngineV2AppAudioPreferencesMediaAdapter {
|
||||||
requestMicrophoneRefresh(room: Room, request: {republish: boolean}): Promise<void>;
|
requestMicrophoneRefresh(room: Room, request: {republish: boolean}): Promise<void>;
|
||||||
configureVoiceInput(room: Room): void;
|
configureVoiceInput(room: Room): void;
|
||||||
handleInputKeybindChange(room: Room, options: {preserveSelfMute: boolean}): void;
|
handleInputKeybindChange(room: Room): void;
|
||||||
applyAllLocalAudioPreferences(room: Room): void;
|
applyAllLocalAudioPreferences(room: Room): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ function createVoiceEngineV2AppAudioPreferencesSnapshot(): VoiceEngineV2AppAudio
|
|||||||
participantMutes: ParticipantVolume.localMutes,
|
participantMutes: ParticipantVolume.localMutes,
|
||||||
connectionVolumes: ParticipantVolume.connectionVolumesByLocalConnectionId,
|
connectionVolumes: ParticipantVolume.connectionVolumesByLocalConnectionId,
|
||||||
streamAudioRevision: StreamAudioPrefs.audioPrefsRevision,
|
streamAudioRevision: StreamAudioPrefs.audioPrefsRevision,
|
||||||
transmitMode: Keybind.transmitMode,
|
|
||||||
pushToMuteActive: Keybind.isPushToMuteEffective(),
|
pushToMuteActive: Keybind.isPushToMuteEffective(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,7 @@ function syncVoiceEngineV2AppAudioPreferences(
|
|||||||
);
|
);
|
||||||
assert.equal(typeof logger.warn, 'function', 'audio preferences sync logger missing warn');
|
assert.equal(typeof logger.warn, 'function', 'audio preferences sync logger missing warn');
|
||||||
if (previous.pushToMuteActive !== current.pushToMuteActive) {
|
if (previous.pushToMuteActive !== current.pushToMuteActive) {
|
||||||
adapter.handleInputKeybindChange(room, {preserveSelfMute: previous.transmitMode === current.transmitMode});
|
adapter.handleInputKeybindChange(room);
|
||||||
}
|
}
|
||||||
const republish = hasVoiceEngineV2MicrophonePublishSettingsChanged(previous.audioSettings, current.audioSettings);
|
const republish = hasVoiceEngineV2MicrophonePublishSettingsChanged(previous.audioSettings, current.audioSettings);
|
||||||
if (republish || hasVoiceEngineV2MicrophoneCaptureSettingsChanged(previous.audioSettings, current.audioSettings)) {
|
if (republish || hasVoiceEngineV2MicrophoneCaptureSettingsChanged(previous.audioSettings, current.audioSettings)) {
|
||||||
|
|||||||
@@ -540,15 +540,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
await this.enforceSpeakPermissionMute(room);
|
await this.enforceSpeakPermissionMute(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pttActive = isPushToTalkActiveFromAppState();
|
if (audioState.effectiveDeaf || audioState.serverMute || audioState.selfMute) {
|
||||||
const ptmActive = isPushToMuteActiveFromAppState();
|
|
||||||
const effectiveMute =
|
|
||||||
audioState.effectiveDeaf ||
|
|
||||||
audioState.serverMute ||
|
|
||||||
audioState.selfMute ||
|
|
||||||
(pttActive && !Keybind.pushToTalkHeld) ||
|
|
||||||
(ptmActive && Keybind.pushToMuteHeld);
|
|
||||||
if (effectiveMute) {
|
|
||||||
this.setAudioPublicationsMuted(room, true, 'voice state update');
|
this.setAudioPublicationsMuted(room, true, 'voice state update');
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeakingOverride(room);
|
||||||
return;
|
return;
|
||||||
@@ -1506,7 +1498,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
serverDeaf: serverVoiceState?.deaf,
|
serverDeaf: serverVoiceState?.deaf,
|
||||||
});
|
});
|
||||||
if (isMutedOrDeafened(audioState)) return;
|
if (isMutedOrDeafened(audioState)) return;
|
||||||
const targetMute = audioState.selfMute || !held;
|
const targetMute = getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls();
|
||||||
const ok = this.setMicrophonePublicationsMuted(room, targetMute, 'push to talk');
|
const ok = this.setMicrophonePublicationsMuted(room, targetMute, 'push to talk');
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
this.syncVoiceState({self_mute: true});
|
this.syncVoiceState({self_mute: true});
|
||||||
@@ -1545,11 +1537,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeakingOverride(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePushToTalkModeChange(
|
handlePushToTalkModeChange(room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
|
||||||
room: Room | null,
|
|
||||||
getCurrentUserVoiceState: () => VoiceState | null,
|
|
||||||
options: {preserveSelfMute?: boolean} = {},
|
|
||||||
): void {
|
|
||||||
assertNullableObjectLike<Room>(room, 'handlePushToTalkModeChange.room');
|
assertNullableObjectLike<Room>(room, 'handlePushToTalkModeChange.room');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
typeof getCurrentUserVoiceState,
|
typeof getCurrentUserVoiceState,
|
||||||
@@ -1558,9 +1546,6 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
);
|
);
|
||||||
Keybind.resetPushToTalkState();
|
Keybind.resetPushToTalkState();
|
||||||
Keybind.resetPushToMuteState();
|
Keybind.resetPushToMuteState();
|
||||||
if (!options.preserveSelfMute && isPushToTalkActiveFromAppState() && LocalVoiceState.getSelfMute()) {
|
|
||||||
LocalVoiceState.updateSelfMute(false);
|
|
||||||
}
|
|
||||||
const serverVoiceState = getCurrentUserVoiceState();
|
const serverVoiceState = getCurrentUserVoiceState();
|
||||||
const audioState = this.getEffectiveAudioState({
|
const audioState = this.getEffectiveAudioState({
|
||||||
serverMute: serverVoiceState?.mute ?? false,
|
serverMute: serverVoiceState?.mute ?? false,
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import type {Room} from 'livekit-client';
|
||||||
|
import {Track} from 'livekit-client';
|
||||||
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
const syncLocalVoiceStateWithServer = vi.fn();
|
||||||
|
const setVoiceEngineV2ParticipantAudioLevelSpeaking = vi.fn(() => true);
|
||||||
|
|
||||||
|
const keybind = {
|
||||||
|
pushToTalkEnabled: true,
|
||||||
|
pushToTalkHeld: false,
|
||||||
|
pushToMuteHeld: false,
|
||||||
|
isPushToTalkEffective: () => keybind.pushToTalkEnabled,
|
||||||
|
isPushToMuteEffective: () => false,
|
||||||
|
setPushToTalkHeld: (held: boolean) => {
|
||||||
|
keybind.pushToTalkHeld = held;
|
||||||
|
},
|
||||||
|
setPushToMuteHeld: (held: boolean) => {
|
||||||
|
keybind.pushToMuteHeld = held;
|
||||||
|
},
|
||||||
|
resetPushToTalkState: () => {
|
||||||
|
keybind.pushToTalkHeld = false;
|
||||||
|
},
|
||||||
|
resetPushToMuteState: () => {
|
||||||
|
keybind.pushToMuteHeld = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const localVoiceState = {
|
||||||
|
selfMute: false,
|
||||||
|
selfDeaf: false,
|
||||||
|
shouldUnmuteOnUndeafen: false,
|
||||||
|
getSelfMute: () => localVoiceState.selfMute,
|
||||||
|
getSelfDeaf: () => localVoiceState.selfDeaf,
|
||||||
|
getMutedByPermission: () => false,
|
||||||
|
getHasUserSetMute: () => true,
|
||||||
|
getHasUserSetDeaf: () => false,
|
||||||
|
updateSelfMute: (muted: boolean) => {
|
||||||
|
localVoiceState.selfMute = muted;
|
||||||
|
},
|
||||||
|
updateSelfDeaf: (deafened: boolean) => {
|
||||||
|
localVoiceState.selfDeaf = deafened;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@lingui/core/macro', () => {
|
||||||
|
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||||
|
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@lingui/react/macro', () => ({
|
||||||
|
Trans: () => null,
|
||||||
|
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/app/I18n', () => ({default: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/input/state/InputKeybind', () => ({default: keybind}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/state/LocalVoiceState', () => ({default: localVoiceState}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: {}, useMediaEngineVersion: () => 0}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/VoiceMediaEngineBridge', () => ({
|
||||||
|
getRoomFromMediaEngine: () => null,
|
||||||
|
getVoiceConnectionContextFromMediaEngine: () => null,
|
||||||
|
getVoiceEngineV2SnapshotFromMediaEngine: () => null,
|
||||||
|
getVoiceStateByConnectionIdFromMediaEngine: () => null,
|
||||||
|
setVoiceEngineV2ParticipantAudioLevelSpeaking: (...args: Array<unknown>) =>
|
||||||
|
setVoiceEngineV2ParticipantAudioLevelSpeaking(...(args as [])),
|
||||||
|
syncLocalVoiceStateWithServer: (...args: Array<unknown>) => syncLocalVoiceStateWithServer(...args),
|
||||||
|
updateLocalParticipantFromRoom: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {installRuntimeBootstrap} = await import('@app/features/platform/state/__fixtures__/RuntimeBootstrapFixture');
|
||||||
|
installRuntimeBootstrap('https://primary.test/api');
|
||||||
|
|
||||||
|
const {default: adapter} = await import('@app/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter');
|
||||||
|
|
||||||
|
interface FakeMicrophonePublication {
|
||||||
|
source: Track.Source;
|
||||||
|
isMuted: boolean;
|
||||||
|
track: {mediaStreamTrack: {readyState: string}};
|
||||||
|
mute: ReturnType<typeof vi.fn>;
|
||||||
|
unmute: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMicrophonePublication(): FakeMicrophonePublication {
|
||||||
|
const publication: FakeMicrophonePublication = {
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
isMuted: true,
|
||||||
|
track: {mediaStreamTrack: {readyState: 'live'}},
|
||||||
|
mute: vi.fn(async () => {
|
||||||
|
publication.isMuted = true;
|
||||||
|
}),
|
||||||
|
unmute: vi.fn(async () => {
|
||||||
|
publication.isMuted = false;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return publication;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoom(): {room: Room; publications: Map<string, FakeMicrophonePublication>} {
|
||||||
|
const publications = new Map<string, FakeMicrophonePublication>();
|
||||||
|
const room = {
|
||||||
|
localParticipant: {
|
||||||
|
identity: 'user_1_connection',
|
||||||
|
audioTrackPublications: publications,
|
||||||
|
trackPublications: publications,
|
||||||
|
},
|
||||||
|
remoteParticipants: new Map(),
|
||||||
|
} as unknown as Room;
|
||||||
|
return {room, publications};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastSyncedSelfMute(): unknown {
|
||||||
|
const call = syncLocalVoiceStateWithServer.mock.calls.at(-1);
|
||||||
|
return (call?.[0] as {self_mute?: boolean} | undefined)?.self_mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastSpeakingOverride(): unknown {
|
||||||
|
return setVoiceEngineV2ParticipantAudioLevelSpeaking.mock.calls.at(-1)?.[1 as never];
|
||||||
|
}
|
||||||
|
|
||||||
|
const noServerVoiceState = () => null;
|
||||||
|
|
||||||
|
describe('push to talk with self-mute', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
keybind.pushToTalkEnabled = true;
|
||||||
|
keybind.pushToTalkHeld = false;
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
localVoiceState.selfDeaf = false;
|
||||||
|
syncLocalVoiceStateWithServer.mockClear();
|
||||||
|
setVoiceEngineV2ParticipantAudioLevelSpeaking.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the microphone muted and reports self_mute while the key is held and the user is self-muted', () => {
|
||||||
|
const {room, publications} = createRoom();
|
||||||
|
const publication = createMicrophonePublication();
|
||||||
|
publications.set('mic', publication);
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
expect(keybind.pushToTalkHeld).toBe(true);
|
||||||
|
expect(publication.unmute).not.toHaveBeenCalled();
|
||||||
|
expect(publication.mute).toHaveBeenCalled();
|
||||||
|
expect(publication.isMuted).toBe(true);
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
expect(lastSpeakingOverride()).toBe(false);
|
||||||
|
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
||||||
|
expect(publication.unmute).not.toHaveBeenCalled();
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates the microphone on the key again once the user unmutes', () => {
|
||||||
|
const {room, publications} = createRoom();
|
||||||
|
const publication = createMicrophonePublication();
|
||||||
|
publications.set('mic', publication);
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
expect(publication.unmute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(lastSyncedSelfMute()).toBe(false);
|
||||||
|
expect(lastSpeakingOverride()).toBe(true);
|
||||||
|
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
||||||
|
expect(publication.isMuted).toBe(true);
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
expect(lastSpeakingOverride()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transmits when the user unmutes while the key is still held', () => {
|
||||||
|
const {room, publications} = createRoom();
|
||||||
|
const publication = createMicrophonePublication();
|
||||||
|
publications.set('mic', publication);
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
expect(publication.isMuted).toBe(true);
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
adapter.handlePushToTalkModeChange(room, noServerVoiceState);
|
||||||
|
expect(keybind.pushToTalkHeld).toBe(false);
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
expect(publication.isMuted).toBe(false);
|
||||||
|
expect(lastSyncedSelfMute()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps self-mute when switching into and out of push to talk', () => {
|
||||||
|
const {room, publications} = createRoom();
|
||||||
|
const publication = createMicrophonePublication();
|
||||||
|
publications.set('mic', publication);
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
keybind.pushToTalkEnabled = false;
|
||||||
|
adapter.handlePushToTalkModeChange(room, noServerVoiceState);
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
keybind.pushToTalkEnabled = true;
|
||||||
|
adapter.handlePushToTalkModeChange(room, noServerVoiceState);
|
||||||
|
expect(localVoiceState.selfMute).toBe(true);
|
||||||
|
expect(publication.unmute).not.toHaveBeenCalled();
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
expect(publication.unmute).not.toHaveBeenCalled();
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
keybind.pushToTalkEnabled = false;
|
||||||
|
adapter.handlePushToTalkModeChange(room, noServerVoiceState);
|
||||||
|
expect(localVoiceState.selfMute).toBe(true);
|
||||||
|
expect(publication.unmute).not.toHaveBeenCalled();
|
||||||
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes a muted microphone when a self-muted push to talk user unmutes without holding the key', async () => {
|
||||||
|
const {room, publications} = createRoom();
|
||||||
|
const enableMicrophone = vi.spyOn(adapter, 'enableMicrophone').mockImplementation(async () => {
|
||||||
|
publications.set('mic', createMicrophonePublication());
|
||||||
|
});
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
await adapter.reconcileEffectiveAudioState(room, {channelId: '1', serverMute: false, serverDeaf: false});
|
||||||
|
expect(enableMicrophone).not.toHaveBeenCalled();
|
||||||
|
expect(publications.size).toBe(0);
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
await adapter.reconcileEffectiveAudioState(room, {channelId: '1', serverMute: false, serverDeaf: false});
|
||||||
|
expect(enableMicrophone).toHaveBeenCalledTimes(1);
|
||||||
|
const publication = publications.get('mic');
|
||||||
|
expect(publication).toBeDefined();
|
||||||
|
expect(publication?.isMuted).toBe(true);
|
||||||
|
expect(publication?.unmute).not.toHaveBeenCalled();
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
expect(publication?.unmute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(publication?.isMuted).toBe(false);
|
||||||
|
expect(lastSyncedSelfMute()).toBe(false);
|
||||||
|
enableMicrophone.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls,
|
||||||
|
selectVoiceEngineV2AppMuteReason,
|
||||||
|
selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload,
|
||||||
|
} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||||
|
import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer';
|
||||||
|
import {
|
||||||
|
availableVoiceEngineV2Capabilities,
|
||||||
|
createVoiceEngineV2InitialSnapshot,
|
||||||
|
type VoiceEngineV2Snapshot,
|
||||||
|
} from '@fluxer/voice_engine_v2/src/core/state';
|
||||||
|
import type {
|
||||||
|
VoiceEngineV2AudioControls,
|
||||||
|
VoiceEngineV2AudioControlsPatch,
|
||||||
|
} from '@fluxer/voice_engine_v2/src/protocol/types';
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
function audio(overrides: Partial<VoiceEngineV2AudioControls>): VoiceEngineV2AudioControls {
|
||||||
|
return {
|
||||||
|
mode: 'pushToTalk',
|
||||||
|
locallyMuted: false,
|
||||||
|
preferredLocallyMuted: false,
|
||||||
|
locallyDeafened: false,
|
||||||
|
mutedByPermission: false,
|
||||||
|
hasUserSetMute: false,
|
||||||
|
hasUserSetDeaf: false,
|
||||||
|
shouldUnmuteOnUndeafen: false,
|
||||||
|
pushToTalkActive: false,
|
||||||
|
pushToMuteActive: false,
|
||||||
|
inputVolume: 100,
|
||||||
|
outputVolume: 100,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotWith(controls: VoiceEngineV2AudioControlsPatch): VoiceEngineV2Snapshot {
|
||||||
|
return transitionVoiceEngineV2(createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities()), {
|
||||||
|
type: 'audioControls.changed',
|
||||||
|
controls,
|
||||||
|
}).snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('push to talk self-mute selectors', () => {
|
||||||
|
it('reports self-mute over a held push to talk key', () => {
|
||||||
|
const controls = audio({locallyMuted: true, pushToTalkActive: true});
|
||||||
|
expect(selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(controls)).toBe(true);
|
||||||
|
expect(selectVoiceEngineV2AppMuteReason({voiceState: null, permissionMuted: false, audio: controls})).toBe('self');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the push to talk gate when not self-muted', () => {
|
||||||
|
const idle = audio({});
|
||||||
|
expect(selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(idle)).toBe(true);
|
||||||
|
expect(selectVoiceEngineV2AppMuteReason({voiceState: null, permissionMuted: false, audio: idle})).toBe(
|
||||||
|
'voice_push_to_talk',
|
||||||
|
);
|
||||||
|
const held = audio({pushToTalkActive: true});
|
||||||
|
expect(selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(held)).toBe(false);
|
||||||
|
expect(selectVoiceEngineV2AppMuteReason({voiceState: null, permissionMuted: false, audio: held})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs self_mute from self-mute and the push to talk gate', () => {
|
||||||
|
expect(selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshotWith({mode: 'pushToTalk'}))).toBe(true);
|
||||||
|
expect(
|
||||||
|
selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
|
||||||
|
snapshotWith({mode: 'pushToTalk', pushToTalkActive: true}),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
|
||||||
|
snapshotWith({mode: 'pushToTalk', pushToTalkActive: true, locallyMuted: true}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps voice activity and permission mutes in the synced self_mute', () => {
|
||||||
|
expect(selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshotWith({mode: 'voiceActivity'}))).toBe(false);
|
||||||
|
expect(
|
||||||
|
selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshotWith({mode: 'voiceActivity', locallyMuted: true})),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
|
||||||
|
snapshotWith({mode: 'voiceActivity', mutedByPermission: true}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
|
||||||
|
snapshotWith({mode: 'pushToMute', pushToMuteActive: true}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -211,11 +211,15 @@ export function selectVoiceEngineV2AppEffectiveSelfMuteForVoiceStatePayload(
|
|||||||
return selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio);
|
return selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(
|
export function selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
|
||||||
source: VoiceEngineV2AppProjectionSource,
|
source: VoiceEngineV2AppProjectionSource,
|
||||||
): boolean {
|
): boolean {
|
||||||
assertNonNullObject(source, 'source');
|
assertNonNullObject(source, 'source');
|
||||||
return selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio);
|
const audio = selectVoiceEngineV2AppLocalMedia(source).audio;
|
||||||
|
return (
|
||||||
|
selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio) ||
|
||||||
|
selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(audio)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio: VoiceEngineV2AudioControls): boolean {
|
function selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio: VoiceEngineV2AudioControls): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "self-mute stays authoritative while push to talk is held and across mode switches",
|
||||||
|
"events": [
|
||||||
|
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||||
|
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||||
|
{"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}},
|
||||||
|
{"type": "microphone.publishSucceeded", "operationId": 2},
|
||||||
|
{"type": "localAudio.muteRequested", "muted": true},
|
||||||
|
{"type": "microphone.setEnabledSucceeded", "operationId": 3},
|
||||||
|
{"type": "audioControls.changed", "controls": {"mode": "pushToTalk", "pushToTalkActive": false}},
|
||||||
|
{"type": "audioControls.changed", "controls": {"pushToTalkActive": true}},
|
||||||
|
{"type": "localAudio.muteRequested", "muted": false},
|
||||||
|
{"type": "microphone.setEnabledSucceeded", "operationId": 4},
|
||||||
|
{"type": "audioControls.changed", "controls": {"pushToTalkActive": false}},
|
||||||
|
{"type": "microphone.setEnabledSucceeded", "operationId": 5},
|
||||||
|
{"type": "localAudio.muteRequested", "muted": true},
|
||||||
|
{"type": "audioControls.changed", "controls": {"pushToTalkActive": true}},
|
||||||
|
{"type": "audioControls.changed", "controls": {"mode": "voiceActivity", "pushToTalkActive": false}}
|
||||||
|
],
|
||||||
|
"expectedCommands": [
|
||||||
|
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||||
|
{"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}},
|
||||||
|
{"type": "microphone.setEnabled", "operationId": 3, "enabled": false},
|
||||||
|
{"type": "microphone.setEnabled", "operationId": 4, "enabled": true},
|
||||||
|
{"type": "microphone.setEnabled", "operationId": 5, "enabled": false}
|
||||||
|
],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"afterEvent": 7,
|
||||||
|
"microphoneEnabled": false,
|
||||||
|
"localSpeakingOverride": false,
|
||||||
|
"audioMode": "pushToTalk",
|
||||||
|
"locallyMuted": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"afterEvent": 8,
|
||||||
|
"microphoneEnabled": false,
|
||||||
|
"localSpeakingOverride": false,
|
||||||
|
"audioMode": "pushToTalk",
|
||||||
|
"locallyMuted": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"afterEvent": 10,
|
||||||
|
"microphoneEnabled": true,
|
||||||
|
"localSpeakingOverride": true,
|
||||||
|
"audioMode": "pushToTalk",
|
||||||
|
"locallyMuted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"afterEvent": 12,
|
||||||
|
"microphoneEnabled": false,
|
||||||
|
"localSpeakingOverride": false,
|
||||||
|
"audioMode": "pushToTalk",
|
||||||
|
"locallyMuted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"afterEvent": 14,
|
||||||
|
"microphoneEnabled": false,
|
||||||
|
"localSpeakingOverride": false,
|
||||||
|
"audioMode": "pushToTalk",
|
||||||
|
"locallyMuted": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expectedSnapshot": {
|
||||||
|
"nextOperationId": 6,
|
||||||
|
"connectionStatus": "connected",
|
||||||
|
"microphoneStatus": "published",
|
||||||
|
"microphoneEnabled": false,
|
||||||
|
"localSpeakingOverride": false,
|
||||||
|
"audioMode": "voiceActivity",
|
||||||
|
"locallyMuted": true,
|
||||||
|
"preferredLocallyMuted": true,
|
||||||
|
"publishedDeviceId": "mic-a",
|
||||||
|
"lastFailureCode": null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import localMediaFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/l
|
|||||||
import microphoneFailureRecoveryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/microphone_failure_recovery.json';
|
import microphoneFailureRecoveryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/microphone_failure_recovery.json';
|
||||||
import permissionsRecoveryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/permissions_recovery.json';
|
import permissionsRecoveryFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/permissions_recovery.json';
|
||||||
import pttPtmFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/ptt_ptm.json';
|
import pttPtmFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/ptt_ptm.json';
|
||||||
|
import pttSelfMuteFixtureJson from '@fluxer/voice_engine_v2/fixtures/event_logs/ptt_self_mute.json';
|
||||||
import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer';
|
import {transitionVoiceEngineV2} from '@fluxer/voice_engine_v2/src/core/reducer';
|
||||||
import {
|
import {
|
||||||
availableVoiceEngineV2Capabilities,
|
availableVoiceEngineV2Capabilities,
|
||||||
@@ -49,6 +50,7 @@ interface LocalMediaFixture {
|
|||||||
const fixtures = [
|
const fixtures = [
|
||||||
localMediaFixtureJson,
|
localMediaFixtureJson,
|
||||||
pttPtmFixtureJson,
|
pttPtmFixtureJson,
|
||||||
|
pttSelfMuteFixtureJson,
|
||||||
deviceHotswapFixtureJson,
|
deviceHotswapFixtureJson,
|
||||||
permissionsRecoveryFixtureJson,
|
permissionsRecoveryFixtureJson,
|
||||||
microphoneFailureRecoveryFixtureJson,
|
microphoneFailureRecoveryFixtureJson,
|
||||||
|
|||||||
Reference in new issue
Block a user