feat(voice): саундборд, звуковая палитра, push-to-talk и статистика соединения
Клиентская часть Фазы 3 (AGENT.md 7.13, 7.14): звуки сервера, локальные
звуки интерфейса и телеметрия медиа.
- саундборд: `api/sounds.ts` (лимиты 512 КБ и 30 на вид, multipart-загрузка,
play, `SOUND_EVENTS`), `lib/soundboard.ts` (blob-кэш `/files/{id}`,
наложение до 8 звуков, громкость = `outputVolume`, тишина при дефене),
попап в панели комнаты (`USE_SOUNDBOARD`), таймер на `rate_limited`,
подпись «включил звук» и защита от двойного воспроизведения;
- звуковая палитра: `SOUNDBOARD_PLAY` и события сервера (вход/выход
участника, упоминание, сообщение, вход/выход из голосовой, свои
микрофон/камера/экран, серверный мьют) → `playEventSound`;
- секция «Звуки» в настройках сервера (`MANAGE_SOUNDS`): два вида со
счётчиками, drag&drop, имя, эмодзи, событие палитры, переименование,
удаление и клиентские проверки файла;
- push-to-talk: режим и клавиша (`event.code`), хук в `AppLayout`, вход в
комнату с выключенным микрофоном, удержание включает публикацию, отпускание
и потеря фокуса выключают, серверный мьют приоритетнее;
- статистика: поповер с разрешением, fps, кодеком, битрейтом, RTT, потерями
и jitter (опрос раз в секунду, остановка при закрытии), выбор качества
публикации low/medium/high, пресеты шаринга экрана 1080p60…480p30;
- данные: `guilds[].sounds` из READY, `GUILD_SOUNDS_UPDATE`, селектор
`useGuildSounds`, тумблеры звуков в «Аудио-видео» (localStorage);
- тесты: `soundboard`, `soundSettings`, `pushToTalk`, `voiceStats` и
расширенный фейковый LiveKit SDK.
This commit is contained in:
@@ -41,6 +41,8 @@ export const PERMISSION_MANAGE_GUILD = 'MANAGE_GUILD';
|
||||
export const PERMISSION_MANAGE_CHANNELS = 'MANAGE_CHANNELS';
|
||||
export const PERMISSION_MANAGE_MESSAGES = 'MANAGE_MESSAGES';
|
||||
export const PERMISSION_MANAGE_EMOJIS = 'MANAGE_EMOJIS';
|
||||
export const PERMISSION_MANAGE_SOUNDS = 'MANAGE_SOUNDS';
|
||||
export const PERMISSION_USE_SOUNDBOARD = 'USE_SOUNDBOARD';
|
||||
export const PERMISSION_SEND_MESSAGES = 'SEND_MESSAGES';
|
||||
export const PERMISSION_ATTACH_FILES = 'ATTACH_FILES';
|
||||
export const PERMISSION_ADD_REACTIONS = 'ADD_REACTIONS';
|
||||
@@ -186,6 +188,40 @@ export function canManageEmojis(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь управлять звуками сервера (AGENT.md 7.13): владелец,
|
||||
* ADMINISTRATOR, MANAGE_SOUNDS или администратор инстанса.
|
||||
*/
|
||||
export function canManageSounds(
|
||||
permissions: readonly string[],
|
||||
ownerId: string,
|
||||
userId: string | undefined,
|
||||
isInstanceAdmin = false,
|
||||
): boolean {
|
||||
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_MANAGE_SOUNDS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли пользователь играть звуки саундборда (AGENT.md 7.13): право
|
||||
* `USE_SOUNDBOARD`. Присутствие в голосовой комнате проверяется отдельно —
|
||||
* сервер отвечает `voice.not_connected`, если мы не в комнате.
|
||||
*/
|
||||
export function canUseSoundboard(permissions: readonly string[], isInstanceAdmin = false): boolean {
|
||||
if (isInstanceAdmin) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
|
||||
hasPermission(permissions, PERMISSION_USE_SOUNDBOARD)
|
||||
);
|
||||
}
|
||||
|
||||
/** Может ли пользователь отправлять сообщения в комнату. */
|
||||
export function canSendMessages(
|
||||
permissions: readonly string[],
|
||||
|
||||
+232
-5
@@ -1,6 +1,6 @@
|
||||
import type { Participant, Room, Track } from 'livekit-client';
|
||||
import type { LocalVideoTrack, Participant, Room, Track } from 'livekit-client';
|
||||
|
||||
import type { VoiceDeviceKind } from '@/stores/voice';
|
||||
import type { VoiceDeviceKind, VoicePublishQuality } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Обёртка над `livekit-client`: единственное место, где клиент знает про SDK.
|
||||
@@ -46,6 +46,60 @@ export interface VoiceParticipant {
|
||||
|
||||
export type VoiceRoomStatus = 'connected' | 'reconnecting' | 'reconnected' | 'disconnected';
|
||||
|
||||
/** Пресеты демонстрации экрана: разрешение и fps выбирает пользователь. */
|
||||
export type ScreenSharePresetId = '1080p60' | '1080p30' | '720p30' | '480p30';
|
||||
|
||||
/** Разрешение и fps, которые уходят в `ScreenShareCaptureOptions`. */
|
||||
export const SCREEN_SHARE_PRESETS: Record<
|
||||
ScreenSharePresetId,
|
||||
{ width: number; height: number; frameRate: number }
|
||||
> = {
|
||||
'1080p60': { width: 1920, height: 1080, frameRate: 60 },
|
||||
'1080p30': { width: 1920, height: 1080, frameRate: 30 },
|
||||
'720p30': { width: 1280, height: 720, frameRate: 30 },
|
||||
'480p30': { width: 854, height: 480, frameRate: 30 },
|
||||
};
|
||||
|
||||
/** Список пресетов в порядке показа: от лучшего к экономному. */
|
||||
export const SCREEN_SHARE_PRESET_IDS: ScreenSharePresetId[] = [
|
||||
'1080p60',
|
||||
'1080p30',
|
||||
'720p30',
|
||||
'480p30',
|
||||
];
|
||||
|
||||
/**
|
||||
* Статистика своей публикации (AGENT.md 7.14): фактические разрешение, fps,
|
||||
* кодек и битрейт, а также качество канала. `null` означает «SDK поле не
|
||||
* отдал» — интерфейс показывает прочерк.
|
||||
*/
|
||||
export interface VoiceStats {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
frameRate: number | null;
|
||||
codec: string | null;
|
||||
/** Битрейт отправки в килобитах в секунду (по приросту `bytesSent`). */
|
||||
bitrateKbps: number | null;
|
||||
roundTripMs: number | null;
|
||||
jitterMs: number | null;
|
||||
packetsLost: number | null;
|
||||
/** Доля потерянных пакетов, 0–100. */
|
||||
packetLossPercent: number | null;
|
||||
}
|
||||
|
||||
/** Пустая статистика: SDK ещё не отдал отчёт. */
|
||||
export const EMPTY_VOICE_STATS: VoiceStats = {
|
||||
width: null,
|
||||
height: null,
|
||||
frameRate: null,
|
||||
codec: null,
|
||||
bitrateKbps: null,
|
||||
roundTripMs: null,
|
||||
jitterMs: null,
|
||||
packetsLost: null,
|
||||
packetLossPercent: null,
|
||||
};
|
||||
|
||||
export interface VoiceRoomCallbacks {
|
||||
/** Изменился состав комнаты или медиа-состояние участников. */
|
||||
onParticipants: (participants: VoiceParticipant[]) => void;
|
||||
@@ -61,7 +115,12 @@ export interface VoiceRoomHandle {
|
||||
dispose: () => void;
|
||||
setMicrophoneEnabled: (enabled: boolean) => Promise<void>;
|
||||
setCameraEnabled: (enabled: boolean) => Promise<void>;
|
||||
setScreenShareEnabled: (enabled: boolean) => Promise<void>;
|
||||
/** Демонстрация экрана: пресет задаёт разрешение и fps захвата. */
|
||||
setScreenShareEnabled: (enabled: boolean, preset?: ScreenSharePresetId) => Promise<void>;
|
||||
/** Качество публикации своей камеры (и шаринга, если он идёт). */
|
||||
setVideoQuality: (quality: VoicePublishQuality) => Promise<void>;
|
||||
/** Статистика своей публикации для попапа «Статистика». */
|
||||
fetchStats: () => Promise<VoiceStats>;
|
||||
switchActiveDevice: (kind: VoiceDeviceKind, deviceId: string) => Promise<void>;
|
||||
participants: () => VoiceParticipant[];
|
||||
}
|
||||
@@ -152,6 +211,140 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
return participants;
|
||||
};
|
||||
|
||||
/**
|
||||
* Своя видеодорожка для смены качества публикации и статистики: сначала
|
||||
* активная (камера, затем демонстрация экрана), иначе — выключенная.
|
||||
* `setPublishingQuality` есть только у `LocalVideoTrack`, поэтому проверяем
|
||||
* метод, а не тип.
|
||||
*/
|
||||
const videoPublication = (): LocalVideoTrack | undefined => {
|
||||
const local = room.localParticipant;
|
||||
let fallback: LocalVideoTrack | undefined;
|
||||
for (const source of [Track.Source.Camera, Track.Source.ScreenShare]) {
|
||||
const publication = local.getTrackPublication(source);
|
||||
const track = publication?.track;
|
||||
if (publication === undefined || track === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!publication.isMuted) {
|
||||
return track as LocalVideoTrack;
|
||||
}
|
||||
fallback = fallback ?? (track as LocalVideoTrack);
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/** Число из отчёта WebRTC: `null`, если поля нет или оно не число. */
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** Предыдущий замер `bytesSent`: битрейт считается по приросту. */
|
||||
let previousSample: { bytes: number; at: number } | null = null;
|
||||
|
||||
/**
|
||||
* Статистика своей публикации. Источники — те же, что в `chrome://webrtc`:
|
||||
* `outbound-rtp` (разрешение, fps, кодек, отправленные байты),
|
||||
* `remote-inbound-rtp` (RTT, jitter, потери) и `candidate-pair` (RTT).
|
||||
* Чего SDK не отдал — остаётся `null`, интерфейс покажет «—».
|
||||
*/
|
||||
const collectStats = async (): Promise<VoiceStats> => {
|
||||
const track = videoPublication();
|
||||
if (track === undefined || typeof track.getRTCStatsReport !== 'function') {
|
||||
previousSample = null;
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
}
|
||||
let report: RTCStatsReport | undefined;
|
||||
try {
|
||||
report = await track.getRTCStatsReport();
|
||||
} catch {
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
}
|
||||
if (report === undefined) {
|
||||
return { ...EMPTY_VOICE_STATS };
|
||||
}
|
||||
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
report.forEach((entry: unknown) => {
|
||||
if (typeof entry === 'object' && entry !== null) {
|
||||
entries.push(entry as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
const codecs = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry['type'] === 'codec' && typeof entry['id'] === 'string') {
|
||||
const mime = entry['mimeType'];
|
||||
codecs.set(entry['id'], typeof mime === 'string' ? mime : '');
|
||||
}
|
||||
}
|
||||
|
||||
const outbound = entries.find(
|
||||
(entry) =>
|
||||
entry['type'] === 'outbound-rtp' &&
|
||||
(entry['kind'] === 'video' || entry['mediaType'] === 'video'),
|
||||
);
|
||||
const remoteInbound = entries.find(
|
||||
(entry) => entry['type'] === 'remote-inbound-rtp' && entry['kind'] !== 'audio',
|
||||
);
|
||||
const candidatePair = entries.find(
|
||||
(entry) => entry['type'] === 'candidate-pair' && entry['state'] === 'succeeded',
|
||||
);
|
||||
|
||||
const stats: VoiceStats = { ...EMPTY_VOICE_STATS };
|
||||
const frameWidth = num(outbound?.['frameWidth']);
|
||||
const frameHeight = num(outbound?.['frameHeight']);
|
||||
const framesPerSecond = num(outbound?.['framesPerSecond']);
|
||||
if (frameWidth !== null) {
|
||||
stats.width = Math.round(frameWidth);
|
||||
}
|
||||
if (frameHeight !== null) {
|
||||
stats.height = Math.round(frameHeight);
|
||||
}
|
||||
if (framesPerSecond !== null) {
|
||||
stats.frameRate = Math.round(framesPerSecond);
|
||||
}
|
||||
const codecId = outbound?.['codecId'];
|
||||
const mimeType = typeof codecId === 'string' ? codecs.get(codecId) : undefined;
|
||||
if (mimeType !== undefined && mimeType !== '') {
|
||||
// «video/VP8» → «VP8»: в интерфейсе нужен короткий кодек.
|
||||
stats.codec = mimeType.split('/')[1]?.toLocaleUpperCase() ?? mimeType;
|
||||
}
|
||||
|
||||
// Битрейт: разница `bytesSent` между замерами, делённая на интервал.
|
||||
const bytesSent = num(outbound?.['bytesSent']);
|
||||
const timestamp = num(outbound?.['timestamp']) ?? Date.now();
|
||||
if (bytesSent !== null) {
|
||||
if (previousSample !== null) {
|
||||
const elapsedMs = Math.max(1, timestamp - previousSample.at);
|
||||
const deltaBytes = Math.max(0, bytesSent - previousSample.bytes);
|
||||
stats.bitrateKbps = Math.round((deltaBytes * 8) / elapsedMs);
|
||||
}
|
||||
previousSample = { bytes: bytesSent, at: timestamp };
|
||||
} else {
|
||||
previousSample = null;
|
||||
}
|
||||
|
||||
const roundTrip =
|
||||
num(remoteInbound?.['roundTripTime']) ?? num(candidatePair?.['currentRoundTripTime']);
|
||||
if (roundTrip !== null) {
|
||||
stats.roundTripMs = Math.round(roundTrip * 1000);
|
||||
}
|
||||
const jitter = num(remoteInbound?.['jitter']);
|
||||
if (jitter !== null) {
|
||||
stats.jitterMs = Math.round(jitter * 1000);
|
||||
}
|
||||
const packetsLost = num(remoteInbound?.['packetsLost']);
|
||||
const packetsSent = num(outbound?.['packetsSent']);
|
||||
if (packetsLost !== null) {
|
||||
stats.packetsLost = Math.max(0, Math.round(packetsLost));
|
||||
if (packetsSent !== null && packetsSent + packetsLost > 0) {
|
||||
stats.packetLossPercent =
|
||||
Math.round(((packetsLost / (packetsSent + packetsLost)) * 100 + Number.EPSILON) * 10) /
|
||||
10;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
};
|
||||
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
const refresh = (): void => {
|
||||
callbacks.onParticipants(snapshotAll(room));
|
||||
@@ -210,11 +403,45 @@ export async function createVoiceRoom(callbacks: VoiceRoomCallbacks): Promise<Vo
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setScreenShareEnabled(enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(enabled);
|
||||
async setScreenShareEnabled(enabled, preset) {
|
||||
if (!enabled) {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
// Разрешение и fps выбирает пользователь: они уходят в захват экрана.
|
||||
const resolution = SCREEN_SHARE_PRESETS[preset ?? '1080p30'];
|
||||
await room.localParticipant.setScreenShareEnabled(true, {
|
||||
resolution: {
|
||||
width: resolution.width,
|
||||
height: resolution.height,
|
||||
frameRate: resolution.frameRate,
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
},
|
||||
|
||||
async setVideoQuality(quality) {
|
||||
const track = videoPublication();
|
||||
// `setPublishingQuality` есть только у локальной видеодорожки: если
|
||||
// публикации нет, менять нечего — предпочтение применится при старте.
|
||||
if (track === undefined || typeof track.setPublishingQuality !== 'function') {
|
||||
return;
|
||||
}
|
||||
const { VideoQuality } = await import('livekit-client');
|
||||
const levels: Record<VoicePublishQuality, (typeof VideoQuality)[keyof typeof VideoQuality]> =
|
||||
{
|
||||
low: VideoQuality.LOW,
|
||||
medium: VideoQuality.MEDIUM,
|
||||
high: VideoQuality.HIGH,
|
||||
};
|
||||
track.setPublishingQuality(levels[quality]);
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
return collectStats();
|
||||
},
|
||||
|
||||
async switchActiveDevice(kind, deviceId) {
|
||||
await room.switchActiveDevice(kind, deviceId === '' ? 'default' : deviceId);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Локальное воспроизведение звуков саундборда и звуковой палитры
|
||||
* (AGENT.md 7.13): сервер только рассылает событие, а файл каждый клиент
|
||||
* скачивает сам (`/files/{file_id}`, тот же origin) и играет локально.
|
||||
*
|
||||
* Движок держит кэш object URL (повторное нажатие не качает файл заново) и
|
||||
* список играющих элементов, чтобы звук можно было остановить: свой звук не
|
||||
* накладывается сам на себя, разные звуки звучат одновременно, а при выходе
|
||||
* из комнаты всё глохнет.
|
||||
*
|
||||
* Громкость берётся из общей громкости приложения (`stores/voice`).
|
||||
*/
|
||||
|
||||
/** Сколько звуков может звучать одновременно: защита от «стены» звука. */
|
||||
export const SOUNDBOARD_MAX_CONCURRENT = 8;
|
||||
|
||||
/** Кэш ссылок на файлы: `file_id` → object URL (или прямой адрес). */
|
||||
const sources = new Map<string, Promise<string>>();
|
||||
/** Играющие элементы по `file_id`: их останавливает `stopSoundFile`. */
|
||||
const playing = new Map<string, Set<HTMLAudioElement>>();
|
||||
/** Порядок запусков: самый старый звук глушится при переполнении. */
|
||||
const order: HTMLAudioElement[] = [];
|
||||
|
||||
/**
|
||||
* Общая громкость приложения в долях единицы (0–1). «Звук выключен» (в том
|
||||
* числе серверный дефен) глушит и саундборд, и звуки событий: 0 означает
|
||||
* «не играть вовсе».
|
||||
*/
|
||||
export function soundVolume(): number {
|
||||
const { outputVolume, headphonesEnabled } = useVoiceStore.getState();
|
||||
if (!headphonesEnabled) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, outputVolume / 100));
|
||||
}
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка на файл звука: сначала пробуем скачать его в blob (тогда браузер
|
||||
* играет из памяти и файл не запрашивается дважды), при неудаче — играем по
|
||||
* прямому адресу `/files/{id}`. Ошибка скачивания не должна ломать звук.
|
||||
*/
|
||||
function resolveSource(fileId: string): Promise<string> {
|
||||
const cached = sources.get(fileId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const url = fileContentUrl(fileId);
|
||||
const pending = (async (): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
return url;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return url;
|
||||
}
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
sources.set(fileId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function track(fileId: string, element: HTMLAudioElement): void {
|
||||
const set = playing.get(fileId) ?? new Set<HTMLAudioElement>();
|
||||
set.add(element);
|
||||
playing.set(fileId, set);
|
||||
order.push(element);
|
||||
}
|
||||
|
||||
function untrack(fileId: string, element: HTMLAudioElement): void {
|
||||
const set = playing.get(fileId);
|
||||
if (set !== undefined) {
|
||||
set.delete(element);
|
||||
if (set.size === 0) {
|
||||
playing.delete(fileId);
|
||||
}
|
||||
}
|
||||
const index = order.indexOf(element);
|
||||
if (index >= 0) {
|
||||
order.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Сколько звуков играет прямо сейчас. */
|
||||
export function playingCount(): number {
|
||||
return order.length;
|
||||
}
|
||||
|
||||
/** Останавливает все экземпляры одного звука (например, перед повтором). */
|
||||
export function stopSoundFile(fileId: string): void {
|
||||
const set = playing.get(fileId);
|
||||
if (set === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const element of [...set]) {
|
||||
stopElement(element);
|
||||
untrack(fileId, element);
|
||||
}
|
||||
}
|
||||
|
||||
/** Останавливает всё: используется при выходе из комнаты и в тестах. */
|
||||
export function stopAllSounds(): void {
|
||||
for (const [fileId, set] of [...playing]) {
|
||||
for (const element of [...set]) {
|
||||
stopElement(element);
|
||||
untrack(fileId, element);
|
||||
}
|
||||
}
|
||||
order.length = 0;
|
||||
}
|
||||
|
||||
function stopElement(element: HTMLAudioElement): void {
|
||||
try {
|
||||
element.pause();
|
||||
element.currentTime = 0;
|
||||
} catch {
|
||||
// Элемент мог не начать играть — это не ошибка.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проигрывает звук. Возвращает `false`, если звук играть не нужно или не
|
||||
* удалось (звук выключен, автовоспроизведение запрещено, формат не
|
||||
* поддержан): вызывающий код не должен показывать ошибку — звук не критичен
|
||||
* для интерфейса.
|
||||
*/
|
||||
export async function playSoundFile(fileId: string, volume = soundVolume()): Promise<boolean> {
|
||||
if (fileId === '' || volume <= 0) {
|
||||
return false;
|
||||
}
|
||||
// Тот же звук не накладывается сам на себя: повтор начинается заново.
|
||||
stopSoundFile(fileId);
|
||||
while (order.length >= SOUNDBOARD_MAX_CONCURRENT) {
|
||||
const oldest = order[0];
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
for (const [key, set] of playing) {
|
||||
if (set.has(oldest)) {
|
||||
untrack(key, oldest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
stopElement(oldest);
|
||||
}
|
||||
|
||||
let element: HTMLAudioElement;
|
||||
try {
|
||||
element = new Audio(await resolveSource(fileId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
element.volume = clampVolume(volume);
|
||||
track(fileId, element);
|
||||
// Конец звука освобождает место для следующего; в jsdom события нет.
|
||||
element.addEventListener?.('ended', () => untrack(fileId, element));
|
||||
try {
|
||||
await element.play();
|
||||
return true;
|
||||
} catch {
|
||||
untrack(fileId, element);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Запуск без ожидания: обработчики событий не должны ждать звука. */
|
||||
export function playSoundFileInBackground(fileId: string, volume?: number): void {
|
||||
void playSoundFile(fileId, volume).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Полная очистка движка: кэш ссылок, играющие элементы (тесты и выход). */
|
||||
export function resetSoundboardEngine(): void {
|
||||
stopAllSounds();
|
||||
for (const pending of sources.values()) {
|
||||
void pending
|
||||
.then((url) => {
|
||||
if (url.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
sources.clear();
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
/** Событие системного сообщения. */
|
||||
export type SystemMessageEvent =
|
||||
| { kind: 'member_join'; userId: string }
|
||||
| { kind: 'member_leave'; userId: string }
|
||||
| { kind: 'member_kick'; userId: string }
|
||||
| { kind: 'channel_created'; name: string; channelType: string };
|
||||
|
||||
@@ -33,7 +34,7 @@ export function parseSystemMessage(content: string): SystemMessageEvent | null {
|
||||
const record = asRecord(payload);
|
||||
const event = record?.['event'];
|
||||
const userId = record?.['user_id'];
|
||||
if (event === 'member_join' || event === 'member_kick') {
|
||||
if (event === 'member_join' || event === 'member_leave' || event === 'member_kick') {
|
||||
return typeof userId === 'string' && userId !== '' ? { kind: event, userId } : null;
|
||||
}
|
||||
if (event === 'channel_created') {
|
||||
|
||||
Reference in New Issue
Block a user