initial commit
This commit is contained in:
426
fluxer_app/src/stores/LocalVoiceStateStore.tsx
Normal file
426
fluxer_app/src/stores/LocalVoiceStateStore.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {makePersistent} from '~/lib/MobXPersistence';
|
||||
import MediaPermissionStore from '~/stores/MediaPermissionStore';
|
||||
import VoiceDevicePermissionStore, {type VoiceDeviceState} from '~/stores/voice/VoiceDevicePermissionStore';
|
||||
|
||||
const logger = new Logger('LocalVoiceStateStore');
|
||||
|
||||
class LocalVoiceStateStore {
|
||||
selfMute = !MediaPermissionStore.isMicrophoneGranted();
|
||||
selfDeaf = false;
|
||||
selfVideo = false;
|
||||
selfStream = false;
|
||||
selfStreamAudio = false;
|
||||
selfStreamAudioMute = false;
|
||||
noiseSuppressionEnabled = true;
|
||||
viewerStreamKey: string | null = null;
|
||||
|
||||
hasUserSetMute = false;
|
||||
hasUserSetDeaf = false;
|
||||
|
||||
private microphonePermissionGranted: boolean | null = MediaPermissionStore.isMicrophoneGranted();
|
||||
private mutedByPermission = !MediaPermissionStore.isMicrophoneGranted();
|
||||
private _disposers: Array<() => void> = [];
|
||||
private lastDevicePermissionStatus: VoiceDeviceState['permissionStatus'] | null =
|
||||
VoiceDevicePermissionStore.getState().permissionStatus;
|
||||
private isNotifyingServerOfPermissionMute = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable<
|
||||
this,
|
||||
'microphonePermissionGranted' | 'mutedByPermission' | '_disposers' | 'isNotifyingServerOfPermissionMute'
|
||||
>(
|
||||
this,
|
||||
{
|
||||
microphonePermissionGranted: false,
|
||||
mutedByPermission: false,
|
||||
_disposers: false,
|
||||
isNotifyingServerOfPermissionMute: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
);
|
||||
this._disposers = [];
|
||||
void this.initPersistence().then(() => {
|
||||
this.enforcePermissionMuteIfNeeded();
|
||||
});
|
||||
this.initializePermissionSync();
|
||||
this.initializeDevicePermissionSync();
|
||||
this.enforcePermissionMuteIfNeeded();
|
||||
}
|
||||
|
||||
private async initPersistence(): Promise<void> {
|
||||
await makePersistent(this, 'LocalVoiceStateStore', [
|
||||
'selfMute',
|
||||
'selfDeaf',
|
||||
'noiseSuppressionEnabled',
|
||||
'hasUserSetMute',
|
||||
'hasUserSetDeaf',
|
||||
]);
|
||||
logger.debug('LocalVoiceStateStore hydrated from localStorage on reload');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposers.forEach((disposer) => disposer());
|
||||
this._disposers = [];
|
||||
}
|
||||
|
||||
private async initializePermissionSync(): Promise<void> {
|
||||
try {
|
||||
let defaultMuteInitialized = false;
|
||||
|
||||
const syncWithPermission = (source: 'init' | 'change') => {
|
||||
if (!MediaPermissionStore.isInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMicGranted = MediaPermissionStore.isMicrophoneGranted();
|
||||
const permissionState = MediaPermissionStore.getMicrophonePermissionState();
|
||||
|
||||
this.microphonePermissionGranted = isMicGranted;
|
||||
|
||||
logger.debug(source === 'init' ? 'Checking microphone permission for sync' : 'Microphone permission changed', {
|
||||
isMicGranted,
|
||||
permissionState,
|
||||
currentMute: this.selfMute,
|
||||
hasUserSetMute: this.hasUserSetMute,
|
||||
mutedByPermission: this.mutedByPermission,
|
||||
});
|
||||
|
||||
if (!isMicGranted) {
|
||||
this.applyPermissionMute();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAutoUnmute = this.mutedByPermission && this.selfMute;
|
||||
const shouldApplyDefaultUnmute = !defaultMuteInitialized && !this.hasUserSetMute && this.selfMute;
|
||||
|
||||
if (shouldAutoUnmute || shouldApplyDefaultUnmute) {
|
||||
logger.info(
|
||||
shouldAutoUnmute
|
||||
? 'Microphone permission granted, auto-unmuting after forced mute'
|
||||
: 'Microphone permission granted, defaulting to unmuted state',
|
||||
{permissionState},
|
||||
);
|
||||
runInAction(() => {
|
||||
this.selfMute = false;
|
||||
});
|
||||
}
|
||||
|
||||
this.mutedByPermission = false;
|
||||
defaultMuteInitialized = true;
|
||||
};
|
||||
|
||||
syncWithPermission('init');
|
||||
|
||||
const disposer = MediaPermissionStore.addChangeListener(() => {
|
||||
syncWithPermission('change');
|
||||
});
|
||||
|
||||
this._disposers.push(disposer);
|
||||
} catch (err) {
|
||||
logger.error('Failed to initialize permission sync', err);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDevicePermissionSync(): void {
|
||||
const disposer = VoiceDevicePermissionStore.subscribe((state) => {
|
||||
this.handleDevicePermissionStatus(state.permissionStatus);
|
||||
});
|
||||
this._disposers.push(disposer);
|
||||
}
|
||||
|
||||
private handleDevicePermissionStatus(status: VoiceDeviceState['permissionStatus']): void {
|
||||
if (status === this.lastDevicePermissionStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastDevicePermissionStatus = status;
|
||||
if (status === 'granted') {
|
||||
this.applyPermissionGrant();
|
||||
} else if (status === 'denied') {
|
||||
this.applyPermissionMute();
|
||||
}
|
||||
}
|
||||
|
||||
private enforcePermissionMuteIfNeeded(): void {
|
||||
const devicePermission = VoiceDevicePermissionStore.getState().permissionStatus;
|
||||
const granted = MediaPermissionStore.isMicrophoneGranted() || devicePermission === 'granted';
|
||||
if (granted) {
|
||||
this.microphonePermissionGranted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.microphonePermissionGranted = false;
|
||||
this.applyPermissionMute();
|
||||
}
|
||||
|
||||
private applyPermissionMute(): void {
|
||||
const shouldNotify = !this.isNotifyingServerOfPermissionMute;
|
||||
|
||||
runInAction(() => {
|
||||
this.microphonePermissionGranted = false;
|
||||
this.mutedByPermission = true;
|
||||
if (!this.selfMute) {
|
||||
this.selfMute = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldNotify) {
|
||||
void this.notifyServerOfPermissionMute();
|
||||
}
|
||||
}
|
||||
|
||||
private applyPermissionGrant(): void {
|
||||
runInAction(() => {
|
||||
this.microphonePermissionGranted = true;
|
||||
if (this.mutedByPermission && this.selfMute && !this.hasUserSetMute) {
|
||||
this.selfMute = false;
|
||||
}
|
||||
this.mutedByPermission = false;
|
||||
});
|
||||
}
|
||||
|
||||
private notifyServerOfPermissionMute(): void {
|
||||
if (this.isNotifyingServerOfPermissionMute) {
|
||||
logger.debug('Skipping recursive notifyServerOfPermissionMute call');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isNotifyingServerOfPermissionMute = true;
|
||||
const store = (
|
||||
window as {_mediaEngineStore?: {syncLocalVoiceStateWithServer?: (p: {self_mute: boolean}) => void}}
|
||||
)._mediaEngineStore;
|
||||
if (store?.syncLocalVoiceStateWithServer) {
|
||||
store.syncLocalVoiceStateWithServer({self_mute: true});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to sync permission-mute to server', {error});
|
||||
} finally {
|
||||
this.isNotifyingServerOfPermissionMute = false;
|
||||
}
|
||||
}
|
||||
|
||||
getSelfMute(): boolean {
|
||||
return this.selfMute;
|
||||
}
|
||||
|
||||
ensurePermissionMute(): void {
|
||||
this.enforcePermissionMuteIfNeeded();
|
||||
}
|
||||
|
||||
getSelfDeaf(): boolean {
|
||||
return this.selfDeaf;
|
||||
}
|
||||
|
||||
getSelfVideo(): boolean {
|
||||
return this.selfVideo;
|
||||
}
|
||||
|
||||
getSelfStream(): boolean {
|
||||
return this.selfStream;
|
||||
}
|
||||
|
||||
getSelfStreamAudio(): boolean {
|
||||
return this.selfStreamAudio;
|
||||
}
|
||||
|
||||
getSelfStreamAudioMute(): boolean {
|
||||
return this.selfStreamAudioMute;
|
||||
}
|
||||
|
||||
getViewerStreamKey(): string | null {
|
||||
return this.viewerStreamKey;
|
||||
}
|
||||
|
||||
updateViewerStreamKey(value: string | null): void {
|
||||
runInAction(() => {
|
||||
this.viewerStreamKey = value;
|
||||
});
|
||||
}
|
||||
|
||||
getNoiseSuppressionEnabled(): boolean {
|
||||
return this.noiseSuppressionEnabled;
|
||||
}
|
||||
|
||||
getHasUserSetMute(): boolean {
|
||||
return this.hasUserSetMute;
|
||||
}
|
||||
|
||||
getHasUserSetDeaf(): boolean {
|
||||
return this.hasUserSetDeaf;
|
||||
}
|
||||
|
||||
toggleSelfMute(): void {
|
||||
runInAction(() => {
|
||||
const newSelfMute = !this.selfMute;
|
||||
const micDenied = this.microphonePermissionGranted === false;
|
||||
|
||||
if (micDenied && !newSelfMute) {
|
||||
this.hasUserSetMute = true;
|
||||
this.mutedByPermission = true;
|
||||
logger.debug('Microphone permission denied, keeping self mute enabled despite toggle');
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasUserSetMute = true;
|
||||
|
||||
if (this.selfDeaf && !newSelfMute) {
|
||||
this.selfMute = false;
|
||||
this.selfDeaf = false;
|
||||
this.hasUserSetDeaf = true;
|
||||
} else {
|
||||
this.selfMute = newSelfMute;
|
||||
}
|
||||
|
||||
logger.debug('User toggled self mute', {newSelfMute, hasUserSetMute: true});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelfDeaf(): void {
|
||||
runInAction(() => {
|
||||
const newSelfDeaf = !this.selfDeaf;
|
||||
this.hasUserSetDeaf = true;
|
||||
|
||||
if (newSelfDeaf) {
|
||||
this.selfMute = true;
|
||||
this.selfDeaf = true;
|
||||
} else {
|
||||
this.selfDeaf = false;
|
||||
}
|
||||
|
||||
logger.debug('User toggled self deaf', {newSelfDeaf, hasUserSetDeaf: true});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelfVideo(): void {
|
||||
runInAction(() => {
|
||||
this.selfVideo = !this.selfVideo;
|
||||
logger.debug('User toggled self video', {selfVideo: this.selfVideo});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelfStream(): void {
|
||||
runInAction(() => {
|
||||
this.selfStream = !this.selfStream;
|
||||
logger.debug('User toggled self stream', {selfStream: this.selfStream});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelfStreamAudio(): void {
|
||||
runInAction(() => {
|
||||
this.selfStreamAudio = !this.selfStreamAudio;
|
||||
logger.debug('User toggled self stream audio', {selfStreamAudio: this.selfStreamAudio});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelfStreamAudioMute(): void {
|
||||
runInAction(() => {
|
||||
this.selfStreamAudioMute = !this.selfStreamAudioMute;
|
||||
logger.debug('User toggled self stream audio mute', {selfStreamAudioMute: this.selfStreamAudioMute});
|
||||
});
|
||||
}
|
||||
|
||||
toggleNoiseSuppression(): void {
|
||||
runInAction(() => {
|
||||
this.noiseSuppressionEnabled = !this.noiseSuppressionEnabled;
|
||||
logger.debug('User toggled noise suppression', {enabled: this.noiseSuppressionEnabled});
|
||||
});
|
||||
}
|
||||
|
||||
updateSelfMute(muted: boolean): void {
|
||||
runInAction(() => {
|
||||
if (this.microphonePermissionGranted === false && !muted) {
|
||||
this.mutedByPermission = true;
|
||||
if (!this.selfMute) {
|
||||
this.selfMute = true;
|
||||
logger.debug('Microphone permission denied, overriding requested unmute');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.selfMute = muted;
|
||||
logger.debug('Self mute updated', {muted});
|
||||
});
|
||||
}
|
||||
|
||||
updateSelfDeaf(deafened: boolean): void {
|
||||
runInAction(() => {
|
||||
this.selfDeaf = deafened;
|
||||
logger.debug('Self deaf updated', {deafened});
|
||||
});
|
||||
}
|
||||
|
||||
updateSelfVideo(video: boolean): void {
|
||||
runInAction(() => {
|
||||
this.selfVideo = video;
|
||||
logger.debug('Self video updated', {video});
|
||||
});
|
||||
}
|
||||
|
||||
updateSelfStream(streaming: boolean): void {
|
||||
runInAction(() => {
|
||||
this.selfStream = streaming;
|
||||
logger.debug('Self stream updated', {streaming});
|
||||
});
|
||||
}
|
||||
|
||||
updateSelfStreamAudio(enabled: boolean): void {
|
||||
runInAction(() => {
|
||||
this.selfStreamAudio = enabled;
|
||||
logger.debug('Self stream audio updated', {enabled});
|
||||
});
|
||||
}
|
||||
|
||||
updateSelfStreamAudioMute(muted: boolean): void {
|
||||
runInAction(() => {
|
||||
this.selfStreamAudioMute = muted;
|
||||
logger.debug('Self stream audio mute updated', {muted});
|
||||
});
|
||||
}
|
||||
|
||||
resetUserPreferences(): void {
|
||||
runInAction(() => {
|
||||
this.hasUserSetMute = false;
|
||||
this.hasUserSetDeaf = false;
|
||||
this.selfMute = false;
|
||||
this.selfDeaf = false;
|
||||
this.selfVideo = false;
|
||||
this.selfStream = false;
|
||||
this.selfStreamAudio = false;
|
||||
this.selfStreamAudioMute = false;
|
||||
this.noiseSuppressionEnabled = true;
|
||||
this.mutedByPermission = false;
|
||||
});
|
||||
if (this.microphonePermissionGranted === false) {
|
||||
logger.debug('Resetting preferences while microphone permission denied, keeping user muted');
|
||||
runInAction(() => {
|
||||
this.selfMute = true;
|
||||
this.mutedByPermission = true;
|
||||
});
|
||||
}
|
||||
logger.info('Reset user voice preferences');
|
||||
}
|
||||
}
|
||||
|
||||
export default new LocalVoiceStateStore();
|
||||
Reference in New Issue
Block a user