Files
game-marathon/docs/tz-obs-widget.md
mamonov.ep 3256c40841 Add widget preview and combined widget
- Add live preview iframe in widget settings modal
- Create combined widget (all-in-one: leaderboard + current + progress)
- Add widget type tabs for switching preview
- Update documentation with completed tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 19:55:48 +03:00

23 KiB
Raw Blame History

ТЗ: 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"
  }
}

Модель токена

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

Эндпоинты виджетов

# Публичные эндпоинты (авторизация через 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:
    """
    Получить статистику владельца токена.
    """

Схемы ответов

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

Роутинг

// App.tsx или router config
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/widget/combined" element={<CombinedWidget />} />

Компонент виджета

// 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 <WidgetLoader />
  if (!data) return null

  return (
    <WidgetContainer theme={theme} transparent={params.get('transparent') === 'true'}>
      <div className="widget-leaderboard">
        <h3 className="widget-title">{data.marathon_title}</h3>
        {data.entries.map((entry) => (
          <LeaderboardRow
            key={entry.rank}
            entry={entry}
            highlight={highlight && entry.is_current_user}
          />
        ))}
      </div>
    </WidgetContainer>
  )
}

Темы оформления

Базовые темы

Dark (по умолчанию)

.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

.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

.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

.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.


Интерфейс настройки

Страница генерации виджетов

В личном кабинете участника добавить раздел "Виджеты для стрима":

// pages/WidgetSettingsPage.tsx
const WidgetSettingsPage = () => {
  const [widgetToken, setWidgetToken] = useState<string | null>(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 (
    <div>
      <h1>Виджеты для OBS</h1>

      {!widgetToken ? (
        <Button onClick={generateToken}>Создать токен</Button>
      ) : (
        <>
          <Section title="Настройки">
            <Select
              label="Тема"
              value={selectedTheme}
              options={['dark', 'light', 'neon']}
              onChange={setSelectedTheme}
            />
            <Input
              label="Участников в лидерборде"
              type="number"
              value={leaderboardCount}
              onChange={setLeaderboardCount}
            />
          </Section>

          <Section title="Ссылки для OBS">
            <WidgetUrlBlock
              title="Лидерборд"
              url={widgetUrl('leaderboard')}
              preview={<LeaderboardPreview />}
            />
            <WidgetUrlBlock
              title="Текущее задание"
              url={widgetUrl('current')}
              preview={<CurrentPreview />}
            />
            <WidgetUrlBlock
              title="Прогресс"
              url={widgetUrl('progress')}
            />
          </Section>

          <Section title="Инструкция">
            <ol>
              <li>Скопируйте нужную ссылку</li>
              <li>В OBS добавьте источник "Browser"</li>
              <li>Вставьте ссылку в поле URL</li>
              <li>Установите размер (рекомендуется: 400x300)</li>
            </ol>
          </Section>
        </>
      )}
    </div>
  )
}

Превью виджетов

Показывать живой превью виджета с текущими настройками:

const WidgetPreview = ({ type, params }) => {
  return (
    <div className="widget-preview">
      <iframe
        src={`/widget/${type}?${params}`}
        width="400"
        height="300"
        style={{ border: 'none', borderRadius: 8 }}
      />
    </div>
  )
}

Безопасность

Токены виджетов

  • Токен привязан к конкретному участнику и марафону
  • Токен можно отозвать (деактивировать)
  • Токен даёт доступ только к публичной информации марафона
  • Нельзя использовать для изменения данных

Rate Limiting

# Ограничения для widget эндпоинтов
WIDGET_RATE_LIMIT = "60/minute"  # 60 запросов в минуту на токен

Валидация токена

async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession) -> WidgetToken:
    widget_token = await db.scalar(
        select(WidgetToken)
        .options(selectinload(WidgetToken.participant))
        .where(
            WidgetToken.token == token,
            WidgetToken.marathon_id == marathon_id,
            WidgetToken.is_active == True,
        )
    )

    if not widget_token:
        raise HTTPException(status_code=401, detail="Invalid widget token")

    if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
        raise HTTPException(status_code=401, detail="Widget token expired")

    return widget_token

План реализации

Этап 1: Backend — модель и токены

  • Создать модель WidgetToken
  • Миграция для таблицы widget_tokens
  • API создания токена (POST /widgets/marathons/{id}/token)
  • API отзыва токена (DELETE /widgets/tokens/{id})
  • API регенерации токена (POST /widgets/tokens/{id}/regenerate)
  • Валидация токена

Этап 2: Backend — API виджетов

  • Эндпоинт /widgets/data/leaderboard
  • Эндпоинт /widgets/data/current
  • Эндпоинт /widgets/data/progress
  • Схемы ответов
  • Rate limiting

Этап 3: Frontend — страницы виджетов

  • Роутинг /widget/*
  • Компонент LeaderboardWidget
  • Компонент CurrentWidget
  • Компонент ProgressWidget
  • Polling обновлений (30 сек)

Этап 4: Frontend — темы и стили

  • Базовые стили виджетов
  • Тема Dark
  • Тема Light
  • Тема Neon
  • Поддержка прозрачного фона
  • Параметры кастомизации через URL (theme, count, avatars, transparent)

Этап 5: Frontend — страница настроек

  • Модальное окно настройки виджетов (WidgetSettingsModal)
  • Форма настроек (тема, количество, аватарки, прозрачность)
  • Копирование URL
  • Превью виджетов (iframe)
  • Инструкция по добавлению в OBS

Этап 6: Тестирование

  • Проверка в OBS Browser Source
  • Тестирование тем
  • Проверка обновления данных
  • Тестирование на разных разрешениях
  • Проверка производительности (polling)

Не реализовано (опционально)

  • Комбинированный виджет
  • Rate limiting для API виджетов

Примеры виджетов

Лидерборд (Dark theme)

┌─────────────────────────────────────┐
│       🏆 Game Marathon              │
├─────────────────────────────────────┤
│  1. 🟣 PlayerOne          1250 pts  │
│  2. 🔵 StreamerPro         980 pts  │
│ ▶3. 🟢 CurrentUser         875 pts ◀│
│  4. 🟡 GamerX              720 pts  │
│  5. 🔴 ProPlayer           650 pts  │
└─────────────────────────────────────┘
      ↑
   аватарки

Текущее задание

┌─────────────────────────────────┐
│  🎮 Dark Souls III             │
├─────────────────────────────────┤
│  Челлендж:                      │
│  Победить Намлесс Кинга        │
│  без брони                      │
│                                 │
│  Очки: +150                     │
│                                 │
│  Сложность: ⭐⭐⭐               │
└─────────────────────────────────┘

Прогресс

┌─────────────────────────────────┐
│  🟢 CurrentUser                │
│  ↑                              │
│ аватарка                        │
├─────────────────────────────────┤
│  Место: #3                      │
│  Очки: 875                      │
│  Стрик: 🔥 5                    │
│  Выполнено: 12                  │
│  Дропнуто: 2                    │
└─────────────────────────────────┘

Дополнительные идеи (будущее)

  • Анимации — анимация при изменении позиций в лидерборде
  • Звуковые оповещения — звук при выполнении задания
  • WebSocket — мгновенные обновления без polling
  • Кастомный CSS — возможность вставить свой CSS
  • Виджет событий — показ активных событий марафона
  • Виджет колеса — мини-версия колеса фортуны