feat(channels): фон комнаты и статичные превью при reduced-motion

- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
  фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
  комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
  нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
This commit is contained in:
2026-09-21 18:08:21 +03:00
parent 91f415e36d
commit 0f0060e8a4
19 changed files with 733 additions and 16 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* Анимированные картинки и настройка «уменьшить движение» (AGENT.md 7.2, 7.7):
* если пользователь просил меньше анимации, показываем статичный первый кадр.
* Кадр рисуется на canvas — серверную генерацию превью ждать не нужно.
*/
import { useEffect, useState } from 'react';
/** Запрос «уменьшить движение» из системных настроек. */
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** Подписка на изменение системной настройки движения. */
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(prefersReducedMotion);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
}
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
const listener = (event: MediaQueryListEvent): void => setReduced(event.matches);
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
}, []);
return reduced;
}
/** Подпись canvas: первый кадр картинки в data URL. */
function firstFrameUrl(image: HTMLImageElement): string | null {
try {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
if (canvas.width === 0 || canvas.height === 0) {
return null;
}
const context = canvas.getContext('2d');
if (context === null) {
return null;
}
context.drawImage(image, 0, 0);
return canvas.toDataURL('image/png');
} catch {
// Чужая картинка без CORS: canvas «испорчен», статичный кадр не сделать.
return null;
}
}
/**
* useStaticFrame возвращает ссылку на первый кадр картинки, когда анимация
* нежелательна; иначе — исходную ссылку. Кадр привязан к источнику, поэтому
* смена картинки не показывает чужой кадр.
*/
export function useStaticFrame(src: string | undefined, reduced: boolean): string | undefined {
const [frame, setFrame] = useState<{ src: string; url: string } | null>(null);
useEffect(() => {
if (src === undefined || src === '' || !reduced) {
return;
}
let cancelled = false;
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
const url = firstFrameUrl(image);
if (!cancelled && url !== null) {
setFrame({ src, url });
}
};
image.src = src;
return () => {
cancelled = true;
};
}, [src, reduced]);
if (!reduced || src === undefined || frame === null || frame.src !== src) {
return src;
}
return frame.url;
}