fix(web): баннер сервера из снапшота, приглашение, встроенные звуки

- READY-снапшот больше не теряет banner/splash/accent: шапка сервера
  показывает баннер сразу после подключения (AGENT.md 7.4)
- GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки
- кнопка приглашения в шапке сервера со ссылкой и копированием
- встроенные звуки интерфейса на Web Audio с разблокировкой по жесту
- object URL превью через безопасные обёртки (jsdom и урезанные webview)
This commit is contained in:
2026-09-21 00:04:49 +03:00
parent 6882516340
commit 4ea2965892
22 changed files with 989 additions and 56 deletions
+202
View File
@@ -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();
}
+26
View File
@@ -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 {
// Ссылка уже недействительна — освобождать нечего.
}
}
+2 -4
View File
@@ -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;
}