fix(web): баннер сервера из снапшота, приглашение, встроенные звуки
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера показывает баннер сразу после подключения (AGENT.md 7.4) - GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки - кнопка приглашения в шапке сервера со ссылкой и копированием - встроенные звуки интерфейса на Web Audio с разблокировкой по жесту - object URL превью через безопасные обёртки (jsdom и урезанные webview)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import type { SoundEvent } from '@/api/sounds';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Встроенные звуки интерфейса (AGENT.md 7.13): короткие сигналы событий, когда
|
||||
* на сервере не загружен свой звук для этого события. Синтезируются через
|
||||
* Web Audio API — бинарных ассетов в бандле нет, а звук можно менять параметрами.
|
||||
*
|
||||
* Звук уважает общие настройки: «звук выключен» (в том числе серверный дефен)
|
||||
* глушит всё, а тумблер «Звуки интерфейса» отключает только встроенные сигналы,
|
||||
* не трогая палитру сервера.
|
||||
*/
|
||||
|
||||
/** Длительность и высота тона для каждого события палитры. */
|
||||
interface Tone {
|
||||
/** Частоты в герцах: играются одна за другой (арпеджио). */
|
||||
frequencies: number[];
|
||||
/** Длительность одной ноты в секундах. */
|
||||
duration: number;
|
||||
/** Тип волны: короткие сигналы звучат мягче на треугольной. */
|
||||
type: OscillatorType;
|
||||
/** Пиковая громкость относительно общей (0–1). */
|
||||
gain: number;
|
||||
}
|
||||
|
||||
/** Ноты событий: подъём — вход, спуск — выход, короткий блип — сообщение. */
|
||||
const TONES: Record<SoundEvent, Tone> = {
|
||||
message: { frequencies: [660], duration: 0.07, type: 'triangle', gain: 0.18 },
|
||||
mention: { frequencies: [880, 1180], duration: 0.08, type: 'triangle', gain: 0.24 },
|
||||
member_join: { frequencies: [520, 780], duration: 0.09, type: 'sine', gain: 0.2 },
|
||||
member_leave: { frequencies: [780, 520], duration: 0.09, type: 'sine', gain: 0.2 },
|
||||
voice_join: { frequencies: [620, 930], duration: 0.08, type: 'sine', gain: 0.22 },
|
||||
voice_leave: { frequencies: [930, 620], duration: 0.08, type: 'sine', gain: 0.22 },
|
||||
mic_on: { frequencies: [740], duration: 0.06, type: 'square', gain: 0.14 },
|
||||
mic_off: { frequencies: [440], duration: 0.06, type: 'square', gain: 0.14 },
|
||||
mute: { frequencies: [380, 300], duration: 0.07, type: 'square', gain: 0.16 },
|
||||
camera_on: { frequencies: [700, 900], duration: 0.06, type: 'triangle', gain: 0.16 },
|
||||
camera_off: { frequencies: [900, 700], duration: 0.06, type: 'triangle', gain: 0.16 },
|
||||
screen_on: { frequencies: [560, 840], duration: 0.07, type: 'sine', gain: 0.18 },
|
||||
screen_off: { frequencies: [840, 560], duration: 0.07, type: 'sine', gain: 0.18 },
|
||||
message_send: { frequencies: [520], duration: 0.05, type: 'triangle', gain: 0.12 },
|
||||
kick: { frequencies: [300, 240], duration: 0.1, type: 'sawtooth', gain: 0.18 },
|
||||
ban: { frequencies: [260, 200], duration: 0.12, type: 'sawtooth', gain: 0.2 },
|
||||
role_grant: { frequencies: [700, 1050], duration: 0.08, type: 'triangle', gain: 0.2 },
|
||||
role_revoke: { frequencies: [1050, 700], duration: 0.08, type: 'triangle', gain: 0.2 },
|
||||
};
|
||||
|
||||
/** Минимальный интерфейс AudioContext: тесты подменяют его своим. */
|
||||
export interface AudioContextLike {
|
||||
currentTime: number;
|
||||
destination: AudioNode;
|
||||
createOscillator: () => OscillatorNode;
|
||||
createGain: () => GainNode;
|
||||
close?: () => Promise<void>;
|
||||
state?: string;
|
||||
resume?: () => Promise<void>;
|
||||
}
|
||||
|
||||
type AudioContextFactory = () => AudioContextLike | null;
|
||||
|
||||
let context: AudioContextLike | null = null;
|
||||
let factory: AudioContextFactory = defaultFactory;
|
||||
/** Сколько звуков играет прямо сейчас: защита от «стены» сигналов. */
|
||||
let active = 0;
|
||||
|
||||
/** Максимум одновременных встроенных сигналов. */
|
||||
export const INTERFACE_SOUND_MAX_CONCURRENT = 6;
|
||||
|
||||
function defaultFactory(): AudioContextLike | null {
|
||||
const globalWindow = window as unknown as {
|
||||
AudioContext?: new () => AudioContextLike;
|
||||
webkitAudioContext?: new () => AudioContextLike;
|
||||
};
|
||||
const Ctor = globalWindow.AudioContext ?? globalWindow.webkitAudioContext;
|
||||
if (Ctor === undefined) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new Ctor();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Громкость сигналов: общая громкость приложения, 0 — тишина. */
|
||||
function volume(): number {
|
||||
const { outputVolume, headphonesEnabled } = useVoiceStore.getState();
|
||||
if (!headphonesEnabled) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, outputVolume / 100));
|
||||
}
|
||||
|
||||
/** Частота события: возвращает описание тона или `null`. */
|
||||
export function interfaceTone(event: SoundEvent): Tone | null {
|
||||
return TONES[event] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* playInterfaceSound играет встроенный сигнал события. Тихо ничего не делает,
|
||||
* если звук выключен, событие неизвестно или Web Audio недоступен.
|
||||
*/
|
||||
export function playInterfaceSound(event: SoundEvent): void {
|
||||
if (!useVoiceStore.getState().interfaceSoundsEnabled) {
|
||||
return;
|
||||
}
|
||||
const level = volume();
|
||||
if (level <= 0) {
|
||||
return;
|
||||
}
|
||||
const tone = interfaceTone(event);
|
||||
if (tone === null || active >= INTERFACE_SOUND_MAX_CONCURRENT) {
|
||||
return;
|
||||
}
|
||||
const ctx = ensureContext();
|
||||
if (ctx === null) {
|
||||
return;
|
||||
}
|
||||
const startedAt = ctx.currentTime;
|
||||
tone.frequencies.forEach((frequency, index) => {
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.type = tone.type;
|
||||
oscillator.frequency.value = frequency;
|
||||
const from = startedAt + index * tone.duration;
|
||||
const to = from + tone.duration;
|
||||
// Плавное появление и затухание: без щелчков на краях.
|
||||
gain.gain.setValueAtTime(0, from);
|
||||
gain.gain.linearRampToValueAtTime(tone.gain * level, from + tone.duration * 0.2);
|
||||
gain.gain.linearRampToValueAtTime(0, to);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
oscillator.start(from);
|
||||
oscillator.stop(to);
|
||||
});
|
||||
active += 1;
|
||||
const total = tone.duration * tone.frequencies.length;
|
||||
window.setTimeout(
|
||||
() => {
|
||||
active = Math.max(0, active - 1);
|
||||
},
|
||||
total * 1000 + 50,
|
||||
);
|
||||
}
|
||||
|
||||
/** ensureContext лениво создаёт AudioContext: до жеста пользователя он спит. */
|
||||
function ensureContext(): AudioContextLike | null {
|
||||
if (context === null) {
|
||||
context = factory();
|
||||
}
|
||||
if (context === null) {
|
||||
return null;
|
||||
}
|
||||
// Браузеры держат контекст в состоянии suspended до взаимодействия.
|
||||
resumeContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/** resumeContext будит контекст: без этого звук остаётся беззвучным. */
|
||||
function resumeContext(ctx: AudioContextLike | null): void {
|
||||
if (ctx !== null && ctx.state === 'suspended' && ctx.resume !== undefined) {
|
||||
void ctx.resume().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let unlockHandler: (() => void) | null = null;
|
||||
|
||||
function detachUnlock(): void {
|
||||
if (unlockHandler === null) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('pointerdown', unlockHandler);
|
||||
window.removeEventListener('keydown', unlockHandler);
|
||||
unlockHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* unlockInterfaceSounds готовит звук к первому жесту: политика автовоспроизведения
|
||||
* не даёт играть сигналам, пришедшим до клика или нажатия клавиши. Вызывается
|
||||
* один раз при старте приложения.
|
||||
*/
|
||||
export function unlockInterfaceSounds(): void {
|
||||
if (unlockHandler !== null) {
|
||||
return;
|
||||
}
|
||||
const handler = (): void => {
|
||||
detachUnlock();
|
||||
// Контекст создаётся уже внутри жеста и сразу разбужен.
|
||||
ensureContext();
|
||||
};
|
||||
unlockHandler = handler;
|
||||
window.addEventListener('pointerdown', handler);
|
||||
window.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
/** Подмена фабрики AudioContext в тестах; `null` возвращает поведение браузера. */
|
||||
export function setAudioContextFactory(next: AudioContextFactory | null): void {
|
||||
factory = next ?? defaultFactory;
|
||||
context = null;
|
||||
active = 0;
|
||||
detachUnlock();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Ссылки на локальные файлы (превью до ответа сервера). `URL.createObjectURL`
|
||||
* есть не во всех окружениях (jsdom, урезанные webview) и может бросить
|
||||
* исключение, поэтому все вызовы идут через эти обёртки.
|
||||
*/
|
||||
export function safeObjectUrl(source: Blob): string | null {
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return URL.createObjectURL(source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeObjectUrl(url: string | null | undefined): void {
|
||||
if (url === null || url === undefined || typeof URL.revokeObjectURL !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Ссылка уже недействительна — освобождать нечего.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { safeObjectUrl } from '@/lib/objectUrl';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
@@ -62,10 +63,7 @@ function resolveSource(fileId: string): Promise<string> {
|
||||
return url;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return url;
|
||||
}
|
||||
return URL.createObjectURL(blob);
|
||||
return safeObjectUrl(blob) ?? url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user