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