619bd91df4
Клиентская часть Фазы 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.
199 lines
7.1 KiB
TypeScript
199 lines
7.1 KiB
TypeScript
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();
|
|
}
|