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

670 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ: 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
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/widget/combined" element={<CombinedWidget />} />
```
### Компонент виджета
```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 <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 (по умолчанию)
```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<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>
)
}
```
### Превью виджетов
Показывать живой превью виджета с текущими настройками:
```tsx
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
```python
# Ограничения для widget эндпоинтов
WIDGET_RATE_LIMIT = "60/minute" # 60 запросов в минуту на токен
```
### Валидация токена
```python
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 — модель и токены ✅
- [x] Создать модель `WidgetToken`
- [x] Миграция для таблицы `widget_tokens`
- [x] API создания токена (`POST /widgets/marathons/{id}/token`)
- [x] API отзыва токена (`DELETE /widgets/tokens/{id}`)
- [x] API регенерации токена (`POST /widgets/tokens/{id}/regenerate`)
- [x] Валидация токена
### Этап 2: Backend — API виджетов ✅
- [x] Эндпоинт `/widgets/data/leaderboard`
- [x] Эндпоинт `/widgets/data/current`
- [x] Эндпоинт `/widgets/data/progress`
- [x] Схемы ответов
- [ ] Rate limiting
### Этап 3: Frontend — страницы виджетов ✅
- [x] Роутинг `/widget/*`
- [x] Компонент `LeaderboardWidget`
- [x] Компонент `CurrentWidget`
- [x] Компонент `ProgressWidget`
- [x] Polling обновлений (30 сек)
### Этап 4: Frontend — темы и стили ✅
- [x] Базовые стили виджетов
- [x] Тема Dark
- [x] Тема Light
- [x] Тема Neon
- [x] Поддержка прозрачного фона
- [x] Параметры кастомизации через URL (theme, count, avatars, transparent)
### Этап 5: Frontend — страница настроек ✅
- [x] Модальное окно настройки виджетов (WidgetSettingsModal)
- [x] Форма настроек (тема, количество, аватарки, прозрачность)
- [x] Копирование URL
- [x] Превью виджетов (iframe)
- [x] Инструкция по добавлению в OBS
### Этап 6: Тестирование
- [ ] Проверка в OBS Browser Source
- [ ] Тестирование тем
- [ ] Проверка обновления данных
- [ ] Тестирование на разных разрешениях
- [ ] Проверка производительности (polling)
### Не реализовано (опционально)
- [x] Комбинированный виджет
- [ ] 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
- **Виджет событий** — показ активных событий марафона
- **Виджет колеса** — мини-версия колеса фортуны