Files
glchat/web/src/lib/soundboard.ts
T

199 lines
7.1 KiB
TypeScript
Raw Normal View History

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();
}