- 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>
670 lines
23 KiB
Markdown
670 lines
23 KiB
Markdown
# ТЗ: 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
|
||
- **Виджет событий** — показ активных событий марафона
|
||
- **Виджет колеса** — мини-версия колеса фортуны
|