ecd7b3be10
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
|
||
import { useVoiceStore, type VoiceDeviceKind } from '@/stores/voice';
|
||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||
|
||
/** Устройство ввода/вывода в том виде, в каком его показывает интерфейс. */
|
||
export interface MediaDevice {
|
||
deviceId: string;
|
||
/** Подпись может быть пустой: браузер скрывает её без разрешения. */
|
||
label: string;
|
||
}
|
||
|
||
export interface MediaDevicesResult {
|
||
devices: MediaDevice[];
|
||
/** Доступен ли `navigator.mediaDevices.enumerateDevices()`. */
|
||
supported: boolean;
|
||
}
|
||
|
||
/**
|
||
* Список устройств нужного вида через `navigator.mediaDevices`.
|
||
*
|
||
* Без разрешения на доступ браузер отдаёт устройства с пустыми подписями —
|
||
* их показываем как «Устройство N». Если API недоступно (старый браузер или
|
||
* тестовое окружение), возвращаем `supported: false`.
|
||
*/
|
||
export function useMediaDevices(kind: VoiceDeviceKind): MediaDevicesResult {
|
||
const supported =
|
||
typeof navigator !== 'undefined' &&
|
||
typeof navigator.mediaDevices?.enumerateDevices === 'function';
|
||
const [devices, setDevices] = useState<MediaDevice[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!supported) {
|
||
// API нет — список остаётся пустым, интерфейс покажет «недоступны».
|
||
return;
|
||
}
|
||
// Ссылку запоминаем: к моменту очистки эффекта API может быть уже недоступно.
|
||
const mediaDevices = navigator.mediaDevices;
|
||
let cancelled = false;
|
||
|
||
const load = (): void => {
|
||
void mediaDevices
|
||
.enumerateDevices()
|
||
.then((list) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
setDevices(
|
||
list
|
||
.filter((device) => device.kind === kind)
|
||
.map((device) => ({ deviceId: device.deviceId, label: device.label })),
|
||
);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setDevices([]);
|
||
}
|
||
});
|
||
};
|
||
|
||
load();
|
||
// Список меняется при подключении гарнитуры — обновляем его на лету.
|
||
mediaDevices.addEventListener?.('devicechange', load);
|
||
return () => {
|
||
cancelled = true;
|
||
mediaDevices.removeEventListener?.('devicechange', load);
|
||
};
|
||
}, [kind, supported]);
|
||
|
||
return { devices, supported };
|
||
}
|
||
|
||
/**
|
||
* Выбранное устройство нужного вида из стора предпочтений.
|
||
* Одна обёртка на три вида — чтобы не дублировать селекторы в компонентах.
|
||
*
|
||
* Смена устройства сразу применяется к активной голосовой сессии
|
||
* (`switchActiveDevice`), а выбор сохраняется в предпочтениях клиента.
|
||
*/
|
||
export function useDeviceChoice(kind: VoiceDeviceKind): {
|
||
value: string;
|
||
select: (deviceId: string) => void;
|
||
} {
|
||
const value = useVoiceStore((state) => {
|
||
if (kind === 'audioinput') {
|
||
return state.micDeviceId;
|
||
}
|
||
if (kind === 'videoinput') {
|
||
return state.cameraDeviceId;
|
||
}
|
||
return state.outputDeviceId;
|
||
});
|
||
const selectDevice = useVoiceStore((state) => state.selectDevice);
|
||
return {
|
||
value,
|
||
select: (deviceId: string) => {
|
||
selectDevice(kind, deviceId);
|
||
void useVoiceConnectionStore.getState().switchActiveDevice(kind, deviceId);
|
||
},
|
||
};
|
||
}
|