fluxer/fluxer_app/src/stores/NewDeviceMonitoringStore.tsx
2026-01-01 21:05:54 +00:00

294 lines
7.8 KiB
TypeScript

/*
* 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 type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
import {makeAutoObservable, runInAction} from 'mobx';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
import VoiceSettingsStore from '~/stores/VoiceSettingsStore';
import VoiceDevicePermissionStore, {type VoiceDeviceState} from '~/stores/voice/VoiceDevicePermissionStore';
const logger = new Logger('NewDeviceMonitoringStore');
type DeviceType = 'input' | 'output';
interface PendingDevicePrompt {
deviceId: string;
deviceName: string;
deviceType: DeviceType;
}
class NewDeviceMonitoringStore {
knownDeviceIds: Array<string> = [];
ignoredDeviceIds: Array<string> = [];
suppressAlerts = false;
private isInitialized = false;
private isStarted = false;
private startPromise: Promise<void> | null = null;
private startEpoch = 0;
private pendingPrompts: Array<PendingDevicePrompt> = [];
private isShowingPrompt = false;
private unsubscribe: (() => void) | null = null;
private i18n: I18n | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
setI18n(i18n: I18n): void {
this.i18n = i18n;
}
private startMonitoring(): void {
if (this.unsubscribe) return;
this.unsubscribe = VoiceDevicePermissionStore.subscribe(this.handleDeviceStateChange);
}
async start(): Promise<void> {
if (this.startPromise) return this.startPromise;
this.isStarted = true;
const epoch = ++this.startEpoch;
this.startPromise = (async () => {
await makePersistent(this, 'NewDeviceMonitoringStore', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts']);
if (!this.isStarted || epoch !== this.startEpoch) return;
this.startMonitoring();
})();
return this.startPromise;
}
private handleDeviceStateChange(state: VoiceDeviceState): void {
if (!this.isStarted) return;
if (state.permissionStatus !== 'granted') {
return;
}
if (this.suppressAlerts) {
return;
}
const currentInputIds = state.inputDevices.map((d) => d.deviceId);
const currentOutputIds = state.outputDevices.map((d) => d.deviceId);
const allCurrentIds = [...currentInputIds, ...currentOutputIds];
if (!this.isInitialized) {
runInAction(() => {
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
this.isInitialized = true;
});
logger.debug('Initialized with known devices', {count: this.knownDeviceIds.length});
return;
}
const newInputDevices = state.inputDevices.filter(
(device) =>
device.deviceId !== 'default' &&
!this.knownDeviceIds.includes(device.deviceId) &&
!this.ignoredDeviceIds.includes(device.deviceId) &&
device.label,
);
const newOutputDevices = state.outputDevices.filter(
(device) =>
device.deviceId !== 'default' &&
!this.knownDeviceIds.includes(device.deviceId) &&
!this.ignoredDeviceIds.includes(device.deviceId) &&
device.label,
);
if (newInputDevices.length > 0 || newOutputDevices.length > 0) {
logger.debug('New devices detected', {
inputs: newInputDevices.map((d) => d.label),
outputs: newOutputDevices.map((d) => d.label),
});
runInAction(() => {
for (const device of newInputDevices) {
this.pendingPrompts.push({
deviceId: device.deviceId,
deviceName: device.label,
deviceType: 'input',
});
this.knownDeviceIds.push(device.deviceId);
}
for (const device of newOutputDevices) {
this.pendingPrompts.push({
deviceId: device.deviceId,
deviceName: device.label,
deviceType: 'output',
});
this.knownDeviceIds.push(device.deviceId);
}
});
this.processNextPrompt();
}
}
private processNextPrompt(): void {
if (!this.isStarted) return;
if (this.isShowingPrompt || this.pendingPrompts.length === 0) {
return;
}
const prompt = this.pendingPrompts.shift();
if (!prompt) {
return;
}
this.isShowingPrompt = true;
this.showNewDeviceModal(prompt);
}
private showNewDeviceModal(prompt: PendingDevicePrompt): void {
if (!this.i18n) {
throw new Error('NewDeviceMonitoringStore: i18n not initialized');
}
const i18n = this.i18n;
const {deviceId, deviceName, deviceType} = prompt;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`New audio device detected!`)}
description={
deviceType === 'input' ? (
<Trans>
Fluxer has found a new audio input device named <strong>{deviceName}</strong>. Do you want to switch to
it?
</Trans>
) : (
<Trans>
Fluxer has found a new audio output device named <strong>{deviceName}</strong>. Do you want to switch to
it?
</Trans>
)
}
primaryText={i18n._(msg`Switch Device`)}
primaryVariant="primary"
secondaryText={i18n._(msg`Not Now`)}
checkboxContent={
<Checkbox>
<Trans>
Don't ask me this again for <strong>{deviceName}</strong>
</Trans>
</Checkbox>
}
onPrimary={(dontAskAgain) => {
if (deviceType === 'input') {
VoiceSettingsStore.updateSettings({inputDeviceId: deviceId});
} else {
VoiceSettingsStore.updateSettings({outputDeviceId: deviceId});
}
if (dontAskAgain) {
this.addToIgnored(deviceId);
}
queueMicrotask(() => this.onModalClosed());
}}
onSecondary={(dontAskAgain) => {
if (dontAskAgain) {
this.addToIgnored(deviceId);
}
queueMicrotask(() => this.onModalClosed());
}}
/>
)),
);
}
private onModalClosed(): void {
this.isShowingPrompt = false;
this.processNextPrompt();
}
private addToIgnored(deviceId: string): void {
if (!this.ignoredDeviceIds.includes(deviceId)) {
runInAction(() => {
this.ignoredDeviceIds.push(deviceId);
});
logger.debug('Added device to ignore list', {deviceId});
}
}
clearIgnoredDevices(): void {
this.ignoredDeviceIds = [];
logger.debug('Cleared all ignored devices');
}
removeFromIgnored(deviceId: string): void {
const index = this.ignoredDeviceIds.indexOf(deviceId);
if (index !== -1) {
this.ignoredDeviceIds.splice(index, 1);
logger.debug('Removed device from ignore list', {deviceId});
}
}
getIgnoredDeviceIds(): ReadonlyArray<string> {
return this.ignoredDeviceIds;
}
setSuppressAlerts(suppress: boolean): void {
this.suppressAlerts = suppress;
logger.debug('Suppress alerts setting changed', {suppress});
}
showTestModal(): void {
this.showNewDeviceModal({
deviceId: 'test-device-id',
deviceName: 'Test Audio Device',
deviceType: 'input',
});
}
dispose(): void {
this.isStarted = false;
this.startPromise = null;
this.startEpoch++;
this.pendingPrompts = [];
this.isShowingPrompt = false;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.isInitialized = false;
}
}
export default new NewDeviceMonitoringStore();