# ТЗ: OBS Виджеты для стрима ## Описание задачи Создать набор виджетов для отображения информации о марафоне в OBS через Browser Source. Виджеты позволяют стримерам показывать зрителям актуальную информацию о марафоне в реальном времени. --- ## Виджеты ### 1. Лидерборд Таблица участников марафона с их позициями и очками. | Поле | Описание | |------|----------| | Место | Позиция в рейтинге (1, 2, 3...) | | Аватар | Аватарка участника (круглая, 32x32 px) | | Никнейм | Имя участника | | Очки | Текущее количество очков | | Стрик | Текущий стрик (опционально) | **Настройки:** - Количество отображаемых участников (3, 5, 10, все) - Подсветка текущего стримера - Показ/скрытие аватарок - Показ/скрытие стриков --- ### 2. Текущее задание Отображает активное задание стримера. | Поле | Описание | |------|----------| | Игра | Название игры | | Задание | Описание челленджа / прохождения | | Очки | Количество очков за выполнение | | Тип | Челлендж / Прохождение | | Прогресс бонусов | Для прохождений: X/Y бонусных челленджей | **Состояния:** - Активное задание — показывает детали - Нет задания — "Ожидание спина" или скрыт --- ### 3. Прогресс марафона Общая статистика стримера в марафоне. | Поле | Описание | |------|----------| | Позиция | Текущее место в рейтинге | | Очки | Набранные очки | | Стрик | Текущий стрик | | Выполнено | Количество выполненных заданий | | Дропнуто | Количество дропнутых заданий | --- ### 4. Комбинированный виджет (опционально) Объединяет несколько блоков в одном виджете: - Мини-лидерборд (топ-3) - Текущее задание - Статистика стримера --- ## Техническая реализация ### Архитектура ``` ┌─────────────────────────────────────────────────────────────────┐ │ OBS Browser Source │ │ │ │ │ ▼ │ │ ┌─────────────────────────────┐ │ │ │ /widget/{type}?params │ │ │ │ │ │ │ │ Frontend страница │ │ │ │ (React / статический HTML)│ │ │ └─────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────┐ │ │ │ WebSocket / Polling │ │ │ │ Обновление данных │ │ │ └─────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────┐ │ │ │ Backend API │ │ │ │ /api/v1/widget/* │ │ │ └─────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### URL структура ``` /widget/leaderboard?marathon={id}&token={token}&theme={theme}&count={count} /widget/current?marathon={id}&token={token}&theme={theme} /widget/progress?marathon={id}&token={token}&theme={theme} /widget/combined?marathon={id}&token={token}&theme={theme} ``` ### Параметры URL | Параметр | Обязательный | Описание | |----------|--------------|----------| | `marathon` | Да | ID марафона | | `token` | Да | Токен виджета (привязан к участнику) | | `theme` | Нет | Тема оформления (dark, light, custom) | | `count` | Нет | Количество участников (для лидерборда) | | `highlight` | Нет | Подсветить пользователя (true/false) | | `avatars` | Нет | Показывать аватарки (true/false, по умолчанию true) | | `fontSize` | Нет | Размер шрифта (sm, md, lg) | | `width` | Нет | Ширина виджета в пикселях | | `transparent` | Нет | Прозрачный фон (true/false) | --- ## Backend API ### Токен виджета Для авторизации виджетов используется специальный токен, привязанный к участнику марафона. Это позволяет: - Идентифицировать стримера для подсветки в лидерборде - Показывать личную статистику и задания - Не требовать полной авторизации в OBS #### Генерация токена ``` POST /api/v1/marathons/{marathon_id}/widget-token Authorization: Bearer {jwt_token} Response: { "token": "wgt_abc123xyz...", "expires_at": null, // Бессрочный или с датой "urls": { "leaderboard": "https://marathon.example.com/widget/leaderboard?marathon=1&token=wgt_abc123xyz", "current": "https://marathon.example.com/widget/current?marathon=1&token=wgt_abc123xyz", "progress": "https://marathon.example.com/widget/progress?marathon=1&token=wgt_abc123xyz" } } ``` #### Модель токена ```python class WidgetToken(Base): __tablename__ = "widget_tokens" id: Mapped[int] = mapped_column(primary_key=True) token: Mapped[str] = mapped_column(String(64), unique=True, index=True) participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id")) marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id")) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True) participant: Mapped["Participant"] = relationship() marathon: Mapped["Marathon"] = relationship() ``` ### Эндпоинты виджетов ```python # Публичные эндпоинты (авторизация через widget token) @router.get("/widget/leaderboard") async def widget_leaderboard( marathon: int, token: str, count: int = 10, db: DbSession ) -> WidgetLeaderboardResponse: """ Получить данные лидерборда для виджета. Возвращает топ участников и позицию владельца токена. """ @router.get("/widget/current") async def widget_current_assignment( marathon: int, token: str, db: DbSession ) -> WidgetCurrentResponse: """ Получить текущее задание владельца токена. """ @router.get("/widget/progress") async def widget_progress( marathon: int, token: str, db: DbSession ) -> WidgetProgressResponse: """ Получить статистику владельца токена. """ ``` ### Схемы ответов ```python class WidgetLeaderboardEntry(BaseModel): rank: int nickname: str avatar_url: str | None total_points: int current_streak: int is_current_user: bool # Для подсветки class WidgetLeaderboardResponse(BaseModel): entries: list[WidgetLeaderboardEntry] current_user_rank: int | None total_participants: int marathon_title: str class WidgetCurrentResponse(BaseModel): has_assignment: bool game_title: str | None game_cover_url: str | None assignment_type: str | None # "challenge" | "playthrough" challenge_title: str | None challenge_description: str | None points: int | None bonus_completed: int | None # Для прохождений bonus_total: int | None class WidgetProgressResponse(BaseModel): nickname: str avatar_url: str | None rank: int total_points: int current_streak: int completed_count: int dropped_count: int marathon_title: str ``` --- ## Frontend ### Структура файлов ``` frontend/ ├── src/ │ ├── pages/ │ │ └── widget/ │ │ ├── LeaderboardWidget.tsx │ │ ├── CurrentWidget.tsx │ │ ├── ProgressWidget.tsx │ │ └── CombinedWidget.tsx │ ├── components/ │ │ └── widget/ │ │ ├── WidgetContainer.tsx │ │ ├── LeaderboardRow.tsx │ │ ├── AssignmentCard.tsx │ │ └── StatsBlock.tsx │ └── styles/ │ └── widget/ │ ├── themes/ │ │ ├── dark.css │ │ ├── light.css │ │ └── neon.css │ └── widget.css ``` ### Роутинг ```tsx // App.tsx или router config } /> } /> } /> } /> ``` ### Компонент виджета ```tsx // pages/widget/LeaderboardWidget.tsx import { useSearchParams } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { widgetApi } from '@/api/widget' const LeaderboardWidget = () => { const [params] = useSearchParams() const marathon = params.get('marathon') const token = params.get('token') const theme = params.get('theme') || 'dark' const count = parseInt(params.get('count') || '5') const highlight = params.get('highlight') !== 'false' const { data, isLoading } = useQuery({ queryKey: ['widget-leaderboard', marathon, token], queryFn: () => widgetApi.getLeaderboard(marathon, token, count), refetchInterval: 30000, // Обновление каждые 30 сек }) if (isLoading) return if (!data) return null return (

{data.marathon_title}

{data.entries.map((entry) => ( ))}
) } ``` --- ## Темы оформления ### Базовые темы #### Dark (по умолчанию) ```css .widget-theme-dark { --widget-bg: rgba(18, 18, 18, 0.95); --widget-text: #ffffff; --widget-text-secondary: #a0a0a0; --widget-accent: #8b5cf6; --widget-highlight: rgba(139, 92, 246, 0.2); --widget-border: rgba(255, 255, 255, 0.1); } ``` #### Light ```css .widget-theme-light { --widget-bg: rgba(255, 255, 255, 0.95); --widget-text: #1a1a1a; --widget-text-secondary: #666666; --widget-accent: #7c3aed; --widget-highlight: rgba(124, 58, 237, 0.1); --widget-border: rgba(0, 0, 0, 0.1); } ``` #### Neon ```css .widget-theme-neon { --widget-bg: rgba(0, 0, 0, 0.9); --widget-text: #00ff88; --widget-text-secondary: #00cc6a; --widget-accent: #ff00ff; --widget-highlight: rgba(255, 0, 255, 0.2); --widget-border: #00ff88; } ``` #### Transparent ```css .widget-transparent { --widget-bg: transparent; } ``` ### Кастомизация через URL ``` ?theme=dark ?theme=light ?theme=neon ?theme=custom&bg=1a1a1a&text=ffffff&accent=ff6600 ?transparent=true ``` --- ## Обновление данных ### Варианты | Способ | Описание | Плюсы | Минусы | |--------|----------|-------|--------| | Polling | Периодический запрос (30 сек) | Простота | Задержка, нагрузка | | WebSocket | Реал-тайм обновления | Мгновенно | Сложность | | SSE | Server-Sent Events | Простой real-time | Односторонний | ### Рекомендация **Polling с интервалом 30 секунд** — оптимальный баланс: - Простая реализация - Минимальная нагрузка на сервер - Достаточная актуальность для стрима Для будущего развития можно добавить WebSocket. --- ## Интерфейс настройки ### Страница генерации виджетов В личном кабинете участника добавить раздел "Виджеты для стрима": ```tsx // pages/WidgetSettingsPage.tsx const WidgetSettingsPage = () => { const [widgetToken, setWidgetToken] = useState(null) const [selectedTheme, setSelectedTheme] = useState('dark') const [leaderboardCount, setLeaderboardCount] = useState(5) const generateToken = async () => { const response = await api.createWidgetToken(marathonId) setWidgetToken(response.token) } const widgetUrl = (type: string) => { const params = new URLSearchParams({ marathon: marathonId.toString(), token: widgetToken, theme: selectedTheme, ...(type === 'leaderboard' && { count: leaderboardCount.toString() }), }) return `${window.location.origin}/widget/${type}?${params}` } return (

Виджеты для OBS

{!widgetToken ? ( ) : ( <>
} /> } />
  1. Скопируйте нужную ссылку
  2. В OBS добавьте источник "Browser"
  3. Вставьте ссылку в поле URL
  4. Установите размер (рекомендуется: 400x300)
)}
) } ``` ### Превью виджетов Показывать живой превью виджета с текущими настройками: ```tsx const WidgetPreview = ({ type, params }) => { return (