mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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]);
|
||||
const wrapperClassName = styles.userAreaInnerWrapper;
|
||||
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
|
||||
const isSelfMuted = isMuted || muteReason === 'self';
|
||||
const microphoneState = selectUserAreaMicrophoneState({
|
||||
effectiveAudioMuted: isMuted,
|
||||
effectiveAudioDeafened: isDeafened,
|
||||
@@ -214,7 +215,6 @@ const UserAreaInner = observer(
|
||||
isPermissionMuted,
|
||||
muteReason,
|
||||
isPushToTalkEffective,
|
||||
isPushToTalkHeld: Keybind.pushToTalkHeld,
|
||||
isPushToMuteEffective: Keybind.isPushToMuteEffective(),
|
||||
isPushToMuteHeld: Keybind.pushToMuteHeld,
|
||||
});
|
||||
@@ -224,7 +224,8 @@ const UserAreaInner = observer(
|
||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_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);
|
||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||
})();
|
||||
@@ -232,7 +233,8 @@ const UserAreaInner = observer(
|
||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_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);
|
||||
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;
|
||||
muteReason: UserAreaMuteReason;
|
||||
isPushToTalkEffective: boolean;
|
||||
isPushToTalkHeld: boolean;
|
||||
isPushToMuteEffective: boolean;
|
||||
isPushToMuteHeld: boolean;
|
||||
}
|
||||
@@ -32,13 +31,13 @@ export function selectUserAreaMicrophoneState(signals: UserAreaMicrophoneSignals
|
||||
if (hardMuted) {
|
||||
return {
|
||||
effectiveMuted: true,
|
||||
muteToggleLocked: muteToggleLocked || signals.isPushToTalkEffective,
|
||||
muteToggleLocked,
|
||||
};
|
||||
}
|
||||
if (signals.isPushToTalkEffective) {
|
||||
return {
|
||||
effectiveMuted: !signals.isPushToTalkHeld,
|
||||
muteToggleLocked: true,
|
||||
effectiveMuted: signals.effectiveAudioMuted || signals.muteReason === 'self',
|
||||
muteToggleLocked,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -521,7 +521,7 @@ class KeybindManager {
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
|
||||
this.disposers.push(reactToPushToTalkModeChanges(() => MediaEngine.handlePushToTalkModeChange()));
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('push-to-talk mode reconcile', () => {
|
||||
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 reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
@@ -238,7 +238,7 @@ describe('push-to-talk mode reconcile', () => {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(effective);
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
|
||||
expect(reconcile).toHaveBeenLastCalledWith();
|
||||
};
|
||||
expectFlip(portalStatus('declined'), false);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
|
||||
@@ -251,8 +251,12 @@ describe('push-to-talk mode reconcile', () => {
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
reconcile.mockClear();
|
||||
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');
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
expect(reconcile).toHaveBeenCalledTimes(2);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,13 @@
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {compareStructural, reaction} from 'mobx';
|
||||
|
||||
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void {
|
||||
export function reactToPushToTalkModeChanges(onChange: () => void): () => void {
|
||||
return reaction(
|
||||
() => ({
|
||||
transmitMode: Keybind.transmitMode,
|
||||
pushToTalk: Keybind.isPushToTalkEffective(),
|
||||
pushToMute: Keybind.isPushToMuteEffective(),
|
||||
}),
|
||||
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}),
|
||||
() => onChange(),
|
||||
{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
|
||||
|
||||
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 {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
|
||||
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> {
|
||||
if (Keybind.isPushToTalkEffective()) {
|
||||
logger.debug('Ignoring self-mute toggle: push-to-talk controls the microphone');
|
||||
return;
|
||||
}
|
||||
const room = MediaEngine.room;
|
||||
const connectedChannelId = MediaEngine.channelId;
|
||||
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
|
||||
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
|
||||
const currentMute = getCurrentSelfMute();
|
||||
const currentDeaf = LocalVoiceState.getSelfDeaf();
|
||||
const willUndeafen = currentDeaf;
|
||||
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> {
|
||||
if (connectionId === MediaEngine.connectionId) {
|
||||
await toggleSelfMute(null);
|
||||
return;
|
||||
}
|
||||
const voiceState = MediaEngine.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) return;
|
||||
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> {
|
||||
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> {
|
||||
|
||||
@@ -667,7 +667,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
||||
const useConnectionSpecificControls =
|
||||
connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId);
|
||||
if (useConnectionSpecificControls) {
|
||||
const isSelfMuted = connectionVoiceState?.self_mute ?? false;
|
||||
const isSelfMuted = VoiceStateCommands.getSelfMuteForConnection(connectionId);
|
||||
const isSelfDeafened = connectionVoiceState?.self_deaf ?? false;
|
||||
const isCameraOn = connectionVoiceState?.self_video ?? false;
|
||||
const isStreaming = connectionVoiceState?.self_stream ?? false;
|
||||
@@ -726,7 +726,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
||||
}
|
||||
if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId));
|
||||
} else if (currentConnectionId && canUseCurrentConnectionAsRoot) {
|
||||
const isSelfMuted = currentUserVoiceState?.self_mute ?? false;
|
||||
const isSelfMuted = VoiceStateCommands.getCurrentSelfMute();
|
||||
const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false;
|
||||
const isCameraOn = currentConnectionVoiceState?.self_video ?? false;
|
||||
const isStreaming = currentConnectionVoiceState?.self_stream ?? false;
|
||||
@@ -909,7 +909,9 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
||||
const bulkActions: Array<MenuItemType> = [];
|
||||
const connectionIds = userVoiceStates.map((u) => u.connectionId);
|
||||
if (isCurrentUser && isCurrentUserConnectedToVoice) {
|
||||
const allMuted = userVoiceStates.every(({voiceState}) => voiceState.self_mute);
|
||||
const allMuted = userVoiceStates.every(({connectionId}) =>
|
||||
VoiceStateCommands.getSelfMuteForConnection(connectionId),
|
||||
);
|
||||
bulkActions.push({
|
||||
icon: (
|
||||
<SelfMuteIcon
|
||||
|
||||
@@ -35,10 +35,10 @@ interface SelfMuteMenuItemProps {
|
||||
export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer(
|
||||
({connectionId, isDeviceSpecific = false, label}) => {
|
||||
const {i18n} = useLingui();
|
||||
const voiceState = connectionId
|
||||
? MediaEngine.getVoiceStateByConnectionId(connectionId)
|
||||
: MediaEngine.getCurrentUserVoiceState();
|
||||
const isSelfMuted = voiceState?.self_mute ?? false;
|
||||
const isSelfMuted =
|
||||
isDeviceSpecific && connectionId
|
||||
? VoiceStateCommands.getSelfMuteForConnection(connectionId)
|
||||
: VoiceStateCommands.getCurrentSelfMute();
|
||||
const handleToggle = useCallback(() => {
|
||||
if (isDeviceSpecific && connectionId) {
|
||||
VoiceStateCommands.toggleSelfMuteForConnection(connectionId);
|
||||
|
||||
@@ -551,6 +551,7 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
|
||||
isGuildDeafened,
|
||||
isPermissionMuted,
|
||||
isPushToTalkEffective,
|
||||
isSelfMuted: effectiveAudioState.effectiveMute || muteReason === 'self',
|
||||
effectiveMuted,
|
||||
canStream,
|
||||
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;
|
||||
isPermissionMuted: boolean;
|
||||
isPushToTalkEffective: boolean;
|
||||
isSelfMuted: boolean;
|
||||
effectiveMuted: boolean;
|
||||
canStream: boolean;
|
||||
isCameraEnabled: boolean;
|
||||
@@ -54,6 +55,7 @@ const DEFAULT_SIGNALS: VoiceControlBarSignals = {
|
||||
isGuildDeafened: false,
|
||||
isPermissionMuted: false,
|
||||
isPushToTalkEffective: false,
|
||||
isSelfMuted: false,
|
||||
effectiveMuted: false,
|
||||
canStream: true,
|
||||
isCameraEnabled: false,
|
||||
@@ -74,7 +76,7 @@ function selectMuteLabel(signals: VoiceControlBarSignals): VoiceControlBarMuteLa
|
||||
if (signals.isGuildDeafened) return 'moderatorDeafened';
|
||||
if (signals.isGuildMuted) return 'moderatorMuted';
|
||||
if (signals.isPermissionMuted) return 'permissionMuted';
|
||||
if (signals.isPushToTalkEffective) return 'pushToTalkHoldHint';
|
||||
if (signals.isPushToTalkEffective && !signals.isSelfMuted) return 'pushToTalkHoldHint';
|
||||
return signals.effectiveMuted ? 'unmute' : 'mute';
|
||||
}
|
||||
|
||||
@@ -97,11 +99,10 @@ function selectScreenShareLabel(signals: VoiceControlBarSignals): VoiceControlBa
|
||||
|
||||
function selectVoiceControlBarState(signals: VoiceControlBarSignals): VoiceControlBarState {
|
||||
const isMicLocked = signals.isGuildMuted || signals.isGuildDeafened || signals.isPermissionMuted;
|
||||
const isMuteToggleLocked = isMicLocked || signals.isPushToTalkEffective;
|
||||
return {
|
||||
mute: {
|
||||
disabled: isMuteToggleLocked,
|
||||
pressed: signals.effectiveMuted,
|
||||
disabled: isMicLocked,
|
||||
pressed: signals.isPushToTalkEffective ? isMicLocked || signals.isSelfMuted : signals.effectiveMuted,
|
||||
label: selectMuteLabel(signals),
|
||||
},
|
||||
deafen: {
|
||||
|
||||
+2
-2
@@ -104,7 +104,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto
|
||||
useMediaEngineVersion();
|
||||
const callHeaderState = useCallHeaderState(channel);
|
||||
const voiceState = MediaEngine.getCurrentUserVoiceState(channel.guildId ?? null);
|
||||
const localSelfMute = LocalVoiceState.getSelfMute();
|
||||
const localSelfMute = VoiceStateCommands.getCurrentSelfMute();
|
||||
const localSelfDeaf = LocalVoiceState.getSelfDeaf();
|
||||
const localSelfVideo = LocalVoiceState.getSelfVideo();
|
||||
const currentLatency = MediaEngine.currentLatency;
|
||||
@@ -112,7 +112,7 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto
|
||||
const voiceServerEndpoint = MediaEngine.voiceServerEndpoint;
|
||||
const connectionId = MediaEngine.connectionId;
|
||||
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 isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
||||
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
|
||||
|
||||
@@ -85,7 +85,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({
|
||||
const connectedGuildId = MediaEngine.guildId;
|
||||
const connectedChannelId = MediaEngine.channelId;
|
||||
const voiceState = MediaEngine.getCurrentUserVoiceState(connectedGuildId);
|
||||
const localSelfMute = LocalVoiceState.getSelfMute();
|
||||
const localSelfMute = VoiceStateCommands.getCurrentSelfMute();
|
||||
const localSelfDeaf = LocalVoiceState.getSelfDeaf();
|
||||
const localSelfVideo = LocalVoiceState.getSelfVideo();
|
||||
const currentLatency = MediaEngine.currentLatency;
|
||||
@@ -96,8 +96,7 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({
|
||||
const isPermissionMuted = isVoicePermissionMuteActive(voiceState, guild.id, channel.id);
|
||||
const isGuildMuted = voiceState?.mute ?? false;
|
||||
const isGuildDeafened = voiceState?.deaf ?? false;
|
||||
const isMuted =
|
||||
(voiceState ? voiceState.self_mute : localSelfMute) || isGuildMuted || isGuildDeafened || isPermissionMuted;
|
||||
const isMuted = localSelfMute || isGuildMuted || isGuildDeafened || isPermissionMuted;
|
||||
const isDeafened = (voiceState ? voiceState.self_deaf : localSelfDeaf) || isGuildDeafened;
|
||||
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
||||
const cameraCapBlocked = useCameraUserCapBlocked(isCameraOn);
|
||||
|
||||
@@ -144,7 +144,7 @@ import voiceEngineV2AppScreenShareExecutionAdapter, {
|
||||
type ScreenShareReconnectSnapshot,
|
||||
shouldRestoreScreenShareAfterReconnect,
|
||||
} 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 {VoiceEngineV2AppStatsHostAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter';
|
||||
import VoiceEngineV2AppSubscriptionAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppSubscriptionAdapter';
|
||||
@@ -1503,7 +1503,7 @@ class MediaEngineFacade extends Store {
|
||||
|
||||
private getEffectiveSelfMuteForVoiceStatePayload(): boolean {
|
||||
this.syncVoiceEngineV2AudioControlsFromAppState();
|
||||
return selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot);
|
||||
return selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(this.voiceEngineV2Snapshot);
|
||||
}
|
||||
|
||||
private cancelPendingServerDisconnect(): void {
|
||||
@@ -1534,7 +1534,10 @@ class MediaEngineFacade extends Store {
|
||||
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(),
|
||||
selfVideo: currentVoiceState?.self_video ?? LocalVoiceState.getSelfVideo(),
|
||||
selfStream: currentVoiceState?.self_stream ?? LocalVoiceState.getSelfStream(),
|
||||
@@ -2041,7 +2044,10 @@ class MediaEngineFacade extends Store {
|
||||
selfStream: payload.self_stream,
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
handlePushToTalkModeChange(options: {preserveSelfMute?: boolean} = {}): void {
|
||||
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(
|
||||
this.room,
|
||||
() => this.getCurrentUserVoiceState(),
|
||||
options,
|
||||
);
|
||||
handlePushToTalkModeChange(): void {
|
||||
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(this.room, () => this.getCurrentUserVoiceState());
|
||||
this.syncVoiceEngineV2AudioControlsFromAppState();
|
||||
}
|
||||
|
||||
@@ -2387,11 +2389,9 @@ class MediaEngineFacade extends Store {
|
||||
requestMicrophoneRefresh: (targetRoom, request) =>
|
||||
voiceEngineV2AppMediaExecutionAdapter.requestMicrophoneRefresh(targetRoom, request),
|
||||
configureVoiceInput: (targetRoom) => voiceEngineV2AppMediaExecutionAdapter.configureVoiceInput(targetRoom),
|
||||
handleInputKeybindChange: (targetRoom, options) =>
|
||||
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(
|
||||
targetRoom,
|
||||
() => this.getCurrentUserVoiceState(),
|
||||
options,
|
||||
handleInputKeybindChange: (targetRoom) =>
|
||||
voiceEngineV2AppMediaExecutionAdapter.handlePushToTalkModeChange(targetRoom, () =>
|
||||
this.getCurrentUserVoiceState(),
|
||||
),
|
||||
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,
|
||||
} from '@app/features/voice/engine/VoiceMediaEngineBridge';
|
||||
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 LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||
import VoiceP2pConsent from '@app/features/voice/state/VoiceP2pConsent';
|
||||
@@ -180,7 +180,7 @@ export function sendVoiceStateConnect(
|
||||
LocalVoiceState.ensurePermissionMute();
|
||||
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
|
||||
const effectiveSelfMute = snapshot
|
||||
? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot)
|
||||
? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot)
|
||||
: LocalVoiceState.getSelfMute();
|
||||
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, effectiveSelfMute);
|
||||
const connectionId = getVoiceConnectionContextFromMediaEngine()?.connectionId ?? null;
|
||||
@@ -227,7 +227,7 @@ export function syncVoiceStateToServer(
|
||||
if (!socket) return;
|
||||
const snapshot = getVoiceEngineV2SnapshotFromMediaEngine();
|
||||
const effectiveSelfMute = snapshot
|
||||
? selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(snapshot)
|
||||
? selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(snapshot)
|
||||
: LocalVoiceState.getSelfMute();
|
||||
const selfMute = applyVoiceSpeakPermissionToSelfMute(guildId, channelId, partial?.self_mute ?? effectiveSelfMute);
|
||||
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 connectionVolumes: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
||||
readonly streamAudioRevision: number;
|
||||
readonly transmitMode: typeof Keybind.transmitMode;
|
||||
readonly pushToMuteActive: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2AppAudioPreferencesMediaAdapter {
|
||||
requestMicrophoneRefresh(room: Room, request: {republish: boolean}): Promise<void>;
|
||||
configureVoiceInput(room: Room): void;
|
||||
handleInputKeybindChange(room: Room, options: {preserveSelfMute: boolean}): void;
|
||||
handleInputKeybindChange(room: Room): void;
|
||||
applyAllLocalAudioPreferences(room: Room): void;
|
||||
}
|
||||
|
||||
@@ -50,7 +49,6 @@ function createVoiceEngineV2AppAudioPreferencesSnapshot(): VoiceEngineV2AppAudio
|
||||
participantMutes: ParticipantVolume.localMutes,
|
||||
connectionVolumes: ParticipantVolume.connectionVolumesByLocalConnectionId,
|
||||
streamAudioRevision: StreamAudioPrefs.audioPrefsRevision,
|
||||
transmitMode: Keybind.transmitMode,
|
||||
pushToMuteActive: Keybind.isPushToMuteEffective(),
|
||||
};
|
||||
}
|
||||
@@ -97,7 +95,7 @@ function syncVoiceEngineV2AppAudioPreferences(
|
||||
);
|
||||
assert.equal(typeof logger.warn, 'function', 'audio preferences sync logger missing warn');
|
||||
if (previous.pushToMuteActive !== current.pushToMuteActive) {
|
||||
adapter.handleInputKeybindChange(room, {preserveSelfMute: previous.transmitMode === current.transmitMode});
|
||||
adapter.handleInputKeybindChange(room);
|
||||
}
|
||||
const republish = hasVoiceEngineV2MicrophonePublishSettingsChanged(previous.audioSettings, current.audioSettings);
|
||||
if (republish || hasVoiceEngineV2MicrophoneCaptureSettingsChanged(previous.audioSettings, current.audioSettings)) {
|
||||
|
||||
@@ -540,15 +540,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||
await this.enforceSpeakPermissionMute(room);
|
||||
return;
|
||||
}
|
||||
const pttActive = isPushToTalkActiveFromAppState();
|
||||
const ptmActive = isPushToMuteActiveFromAppState();
|
||||
const effectiveMute =
|
||||
audioState.effectiveDeaf ||
|
||||
audioState.serverMute ||
|
||||
audioState.selfMute ||
|
||||
(pttActive && !Keybind.pushToTalkHeld) ||
|
||||
(ptmActive && Keybind.pushToMuteHeld);
|
||||
if (effectiveMute) {
|
||||
if (audioState.effectiveDeaf || audioState.serverMute || audioState.selfMute) {
|
||||
this.setAudioPublicationsMuted(room, true, 'voice state update');
|
||||
this.syncLocalSpeakingOverride(room);
|
||||
return;
|
||||
@@ -1506,7 +1498,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||
serverDeaf: serverVoiceState?.deaf,
|
||||
});
|
||||
if (isMutedOrDeafened(audioState)) return;
|
||||
const targetMute = audioState.selfMute || !held;
|
||||
const targetMute = getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls();
|
||||
const ok = this.setMicrophonePublicationsMuted(room, targetMute, 'push to talk');
|
||||
if (!ok) {
|
||||
this.syncVoiceState({self_mute: true});
|
||||
@@ -1545,11 +1537,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||
this.syncLocalSpeakingOverride(room);
|
||||
}
|
||||
|
||||
handlePushToTalkModeChange(
|
||||
room: Room | null,
|
||||
getCurrentUserVoiceState: () => VoiceState | null,
|
||||
options: {preserveSelfMute?: boolean} = {},
|
||||
): void {
|
||||
handlePushToTalkModeChange(room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
|
||||
assertNullableObjectLike<Room>(room, 'handlePushToTalkModeChange.room');
|
||||
assert.equal(
|
||||
typeof getCurrentUserVoiceState,
|
||||
@@ -1558,9 +1546,6 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||
);
|
||||
Keybind.resetPushToTalkState();
|
||||
Keybind.resetPushToMuteState();
|
||||
if (!options.preserveSelfMute && isPushToTalkActiveFromAppState() && LocalVoiceState.getSelfMute()) {
|
||||
LocalVoiceState.updateSelfMute(false);
|
||||
}
|
||||
const serverVoiceState = getCurrentUserVoiceState();
|
||||
const audioState = this.getEffectiveAudioState({
|
||||
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);
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2AppIntentSelfMuteForVoiceStatePayload(
|
||||
export function selectVoiceEngineV2AppSyncSelfMuteForVoiceStatePayload(
|
||||
source: VoiceEngineV2AppProjectionSource,
|
||||
): boolean {
|
||||
assertNonNullObject(source, 'source');
|
||||
return selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(selectVoiceEngineV2AppLocalMedia(source).audio);
|
||||
const audio = selectVoiceEngineV2AppLocalMedia(source).audio;
|
||||
return (
|
||||
selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio) ||
|
||||
selectVoiceEngineV2AppEffectiveSelfMuteFromAudioControls(audio)
|
||||
);
|
||||
}
|
||||
|
||||
function selectVoiceEngineV2AppIntentSelfMuteFromAudioControls(audio: VoiceEngineV2AudioControls): boolean {
|
||||
|
||||
Reference in new issue
Block a user