Initial commit — BashyOverlay v1
Twitch stream event platform with OBS browser source overlay. Features: - Twitch OAuth login (streamer + mod access) - EventSub WebSocket for subs, gift subs, bits, channel points, follows, raids - Chat commands via tmi.js in the overlay - Gap audio — tone plays on new chat message after silence, volume/pitch scale with gap length - Self-hosted media library (upload sounds and videos) - Action configuration — map any event to sound, video, or alert - Per-action cooldowns, test button, enable/disable toggle - Multiple actions per event (all matching actions fire) - Activity log dashboard with EventSub connection status - Layout editor — iframe-based WYSIWYG with drag handles, live style preview - Custom CSS and custom JS injection into overlay - Custom DOM events (bashyoverlay:sub, bashyoverlay:raid, etc.) for custom JS hooks - deploy.sh — one-shot setup and launch script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
6ad661c220
37 changed files with 2180 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
TWITCH_CLIENT_ID=your_client_id
|
||||
TWITCH_CLIENT_SECRET=your_client_secret
|
||||
TWITCH_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||
|
||||
SECRET_KEY=change_this_to_a_random_string
|
||||
APP_BASE_URL=http://localhost:8000
|
||||
DATABASE_URL=sqlite:///./bashyoverlay.db
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
*.db
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
static/media/*
|
||||
!static/media/.gitkeep
|
||||
95
SPEC.md
Normal file
95
SPEC.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# BashyOverlay — spec
|
||||
|
||||
Self-hosted Twitch stream event platform. Subs, bits, channel points, and chat commands trigger configurable sound and video reactions. Think StreamElements/Streamlabs alerts, but owned and customisable.
|
||||
|
||||
No smart lights.
|
||||
|
||||
## Event sources
|
||||
|
||||
| Source | How |
|
||||
|---|---|
|
||||
| Chat commands (`!name`) | tmi.js IRC WebSocket — no auth needed for public chat |
|
||||
| Subs, bits, channel points | Twitch EventSub WebSocket — requires OAuth token |
|
||||
|
||||
## Action types
|
||||
|
||||
| Action | Notes |
|
||||
|---|---|
|
||||
| Play a sound | From Blerp library — see open questions |
|
||||
| Play a video/clip overlay | From HudFX library — see open questions |
|
||||
| On-screen alert | Text, image, or animation shown in the OBS browser source |
|
||||
|
||||
## Media libraries
|
||||
|
||||
- **Sounds — Blerp.** Meme/clip sound platform. Has Twitch integration history. API details need confirming before building — check developer docs for programmatic library access and licensing.
|
||||
- **Videos — HudFX.** Overlay video/animation library. API access and terms need confirming before building.
|
||||
|
||||
Both are external dependencies. If their APIs aren't open enough, fallback is a self-hosted media library (upload your own files via the management UI).
|
||||
|
||||
## The overlay
|
||||
|
||||
OBS browser source pointed at the local management server. Receives events from the backend via WebSocket and plays the configured reaction.
|
||||
|
||||
### Chat gap audio
|
||||
|
||||
Passive audio notification for slow chat — not tied to a command, just fires when a new message arrives after a long silence:
|
||||
- **Under 30 seconds** — no sound
|
||||
- **30s to ~2 minutes** — volume scales up with gap
|
||||
- **2+ minutes** — full volume + pitch scales up (caps ~5 min)
|
||||
|
||||
Thresholds configurable in management UI.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Twitch ──EventSub WebSocket──► Backend ──WebSocket──► OBS Browser Source
|
||||
Twitch ──IRC (tmi.js)────────────────────────────────► OBS Browser Source
|
||||
```
|
||||
|
||||
The overlay holds the tmi.js connection directly. The backend holds the EventSub connection and relays events to the overlay.
|
||||
|
||||
## Management platform
|
||||
|
||||
Local web app running on the streamer's machine.
|
||||
|
||||
**Stack:** FastAPI + SQLite + Jinja2 + HTMX (same as IllBring)
|
||||
|
||||
**Features:**
|
||||
- Configure actions per event type (sub, bits tier, channel point reward, chat command)
|
||||
- Search and preview Blerp sounds
|
||||
- Browse and preview HudFX videos
|
||||
- Upload custom sounds/videos as fallback
|
||||
- Configure gap-audio thresholds
|
||||
- Activity log / dashboard
|
||||
- User access via Twitch OAuth — mods verified against channel mod list, no separate user table
|
||||
|
||||
## Authentication
|
||||
|
||||
Twitch OAuth throughout:
|
||||
- Streamer logs in → full admin access
|
||||
- Mods log in → access if verified as mod of the channel via Twitch API
|
||||
- Token used for EventSub subscriptions
|
||||
|
||||
## Deferred
|
||||
|
||||
- **Crypto payment notifications** — watch a wallet address for incoming transactions via a blockchain API (Alchemy, Moralis, etc.), fire an overlay event on receipt. Which chains to support and whether to use raw wallet watching vs a payment processor (BTCPay Server, etc.) TBD.
|
||||
|
||||
## Estimated effort
|
||||
|
||||
| Piece | Effort |
|
||||
|---|---|
|
||||
| OBS overlay + gap audio | 1–2 hours |
|
||||
| Twitch OAuth | half day |
|
||||
| EventSub WebSocket + subscriptions | half–1 day |
|
||||
| Event → action mapping + management UI | 1 day |
|
||||
| Blerp integration | half day (pending API review) |
|
||||
| HudFX integration | half day (pending API review) |
|
||||
| Dashboard / activity log | half day |
|
||||
| **Total v1** | **~3–4 days** |
|
||||
|
||||
## Open questions before building
|
||||
|
||||
- Does Blerp have a public API with library search + programmatic playback?
|
||||
- Does HudFX expose a video library via API?
|
||||
- If either doesn't — self-hosted media library, or a different provider?
|
||||
- Crypto: which chains, wallet watching vs payment processor?
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
64
app/auth.py
Normal file
64
app/auth.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import os
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session
|
||||
from app.models import User
|
||||
|
||||
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID", "")
|
||||
TWITCH_CLIENT_SECRET = os.getenv("TWITCH_CLIENT_SECRET", "")
|
||||
TWITCH_REDIRECT_URI = os.getenv("TWITCH_REDIRECT_URI", "http://localhost:8000/auth/callback")
|
||||
|
||||
SCOPES = "channel:read:subscriptions bits:read channel:read:redemptions moderator:read:followers"
|
||||
|
||||
TWITCH_AUTH_URL = (
|
||||
"https://id.twitch.tv/oauth2/authorize"
|
||||
f"?client_id={TWITCH_CLIENT_ID}"
|
||||
f"&redirect_uri={TWITCH_REDIRECT_URI}"
|
||||
"&response_type=code"
|
||||
f"&scope={SCOPES.replace(' ', '+')}"
|
||||
)
|
||||
|
||||
|
||||
async def exchange_code(code: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post("https://id.twitch.tv/oauth2/token", data={
|
||||
"client_id": TWITCH_CLIENT_ID,
|
||||
"client_secret": TWITCH_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": TWITCH_REDIRECT_URI,
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def get_twitch_user(access_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
"https://api.twitch.tv/helix/users",
|
||||
headers={"Authorization": f"Bearer {access_token}", "Client-Id": TWITCH_CLIENT_ID},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["data"][0]
|
||||
|
||||
|
||||
def get_current_user(request: Request, session: Session = Depends(get_session)) -> Optional[User]:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
return session.get(User, user_id)
|
||||
|
||||
|
||||
def require_user(request: Request, session: Session = Depends(get_session)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=307, headers={"Location": "/auth/login"})
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=307, headers={"Location": "/auth/login"})
|
||||
return user
|
||||
15
app/cooldowns.py
Normal file
15
app/cooldowns.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from datetime import datetime
|
||||
|
||||
_store: dict[int, tuple[datetime, int]] = {}
|
||||
|
||||
|
||||
def is_on_cooldown(action_id: int) -> bool:
|
||||
if action_id not in _store:
|
||||
return False
|
||||
triggered_at, duration = _store[action_id]
|
||||
return (datetime.utcnow() - triggered_at).total_seconds() < duration
|
||||
|
||||
|
||||
def set_cooldown(action_id: int, duration_seconds: int) -> None:
|
||||
if duration_seconds > 0:
|
||||
_store[action_id] = (datetime.utcnow(), duration_seconds)
|
||||
48
app/database.py
Normal file
48
app/database.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./bashyoverlay.db")
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
|
||||
def create_db() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def get_settings():
|
||||
from app.models import Settings
|
||||
with Session(engine) as session:
|
||||
s = session.exec(select(Settings)).first()
|
||||
if not s:
|
||||
s = Settings()
|
||||
session.add(s)
|
||||
session.commit()
|
||||
session.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
def get_layout():
|
||||
from app.models import OverlayLayout
|
||||
with Session(engine) as session:
|
||||
layout = session.exec(select(OverlayLayout)).first()
|
||||
if not layout:
|
||||
layout = OverlayLayout()
|
||||
session.add(layout)
|
||||
session.commit()
|
||||
session.refresh(layout)
|
||||
return layout
|
||||
|
||||
|
||||
def get_admin_user():
|
||||
from app.models import User
|
||||
with Session(engine) as session:
|
||||
return session.exec(select(User).where(User.is_admin == True)).first()
|
||||
109
app/dispatch.py
Normal file
109
app/dispatch.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from typing import Optional
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.cooldowns import is_on_cooldown, set_cooldown
|
||||
from app.database import engine
|
||||
from app.models import ActivityLog, EventAction, MediaFile
|
||||
from app.ws import manager
|
||||
|
||||
|
||||
async def dispatch_event(
|
||||
event_type: str,
|
||||
username: Optional[str] = None,
|
||||
event_detail: Optional[str] = None,
|
||||
bits_amount: Optional[int] = None,
|
||||
) -> None:
|
||||
with Session(engine) as session:
|
||||
actions = session.exec(
|
||||
select(EventAction)
|
||||
.where(EventAction.event_type == event_type)
|
||||
.where(EventAction.enabled == True)
|
||||
).all()
|
||||
|
||||
fired = []
|
||||
for action in actions:
|
||||
if not _matches(action, event_detail, bits_amount):
|
||||
continue
|
||||
if is_on_cooldown(action.id):
|
||||
continue
|
||||
|
||||
payload = _resolve_payload(action, session)
|
||||
alert_text = action.alert_text or _default_alert(event_type, username, event_detail, bits_amount)
|
||||
|
||||
set_cooldown(action.id, action.cooldown_seconds)
|
||||
await manager.broadcast({
|
||||
"type": "execute",
|
||||
"event_type": event_type,
|
||||
"action_type": action.action_type,
|
||||
"media_url": payload.get("media_url"),
|
||||
"alert_text": alert_text,
|
||||
})
|
||||
fired.append(f"{action.action_type}: {payload.get('media_url') or alert_text or ''}")
|
||||
|
||||
action_taken = ", ".join(fired) if fired else None
|
||||
_log(session, event_type, username, event_detail, bits_amount, action_taken)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def _matches(action: EventAction, event_detail: Optional[str], bits_amount: Optional[int]) -> bool:
|
||||
if not action.event_detail:
|
||||
return True
|
||||
if action.event_type == "bits" and bits_amount is not None:
|
||||
try:
|
||||
return bits_amount >= int(action.event_detail)
|
||||
except ValueError:
|
||||
return False
|
||||
if action.event_type == "command":
|
||||
return action.event_detail.lower() == (event_detail or "").lower()
|
||||
return action.event_detail == event_detail
|
||||
|
||||
|
||||
def _resolve_payload(action: EventAction, session: Session) -> dict:
|
||||
if action.media_file_id:
|
||||
media = session.get(MediaFile, action.media_file_id)
|
||||
if media:
|
||||
return {"media_url": f"/static/media/{media.filename}"}
|
||||
return {}
|
||||
|
||||
|
||||
def _log(session, event_type, username, event_detail, bits_amount, action_taken):
|
||||
session.add(ActivityLog(
|
||||
event_type=event_type,
|
||||
username=username,
|
||||
detail=_detail_str(event_type, event_detail, bits_amount),
|
||||
action_taken=action_taken,
|
||||
))
|
||||
|
||||
|
||||
def _detail_str(event_type, event_detail, bits_amount) -> Optional[str]:
|
||||
if event_type == "sub":
|
||||
return f"tier {event_detail}" if event_detail else None
|
||||
if event_type == "bits":
|
||||
return f"{bits_amount} bits" if bits_amount else None
|
||||
if event_type == "channel_points":
|
||||
return event_detail
|
||||
if event_type == "command":
|
||||
return f"!{event_detail}" if event_detail else None
|
||||
if event_type == "raid":
|
||||
return f"{event_detail} viewers" if event_detail else None
|
||||
return None
|
||||
|
||||
|
||||
def _default_alert(event_type, username, event_detail, bits_amount) -> Optional[str]:
|
||||
u = username or "Someone"
|
||||
if event_type == "sub":
|
||||
return f"{u} just subscribed!"
|
||||
if event_type == "gift_sub":
|
||||
return f"{u} gifted a sub!"
|
||||
if event_type == "bits":
|
||||
return f"{u} cheered {bits_amount} bits!"
|
||||
if event_type == "channel_points":
|
||||
return f"{u} redeemed channel points!"
|
||||
if event_type == "follow":
|
||||
return f"{u} just followed!"
|
||||
if event_type == "raid":
|
||||
viewers = f" with {event_detail} viewers" if event_detail else ""
|
||||
return f"{u} is raiding{viewers}!"
|
||||
return None
|
||||
141
app/eventsub.py
Normal file
141
app/eventsub.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
from app.dispatch import dispatch_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EVENTSUB_WS_URL = "wss://eventsub.wss.twitch.tv/ws"
|
||||
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID", "")
|
||||
TWITCH_CLIENT_SECRET = os.getenv("TWITCH_CLIENT_SECRET", "")
|
||||
|
||||
SUBSCRIPTIONS = [
|
||||
("channel.subscribe", "1", lambda bid: {"broadcaster_user_id": bid}),
|
||||
("channel.subscription.message", "1", lambda bid: {"broadcaster_user_id": bid}),
|
||||
("channel.subscription.gift", "1", lambda bid: {"broadcaster_user_id": bid}),
|
||||
("channel.cheer", "1", lambda bid: {"broadcaster_user_id": bid}),
|
||||
("channel.channel_points_custom_reward_redemption.add", "1", lambda bid: {"broadcaster_user_id": bid}),
|
||||
("channel.follow", "2", lambda bid: {"broadcaster_user_id": bid, "moderator_user_id": bid}),
|
||||
("channel.raid", "1", lambda bid: {"to_broadcaster_user_id": bid}),
|
||||
]
|
||||
|
||||
|
||||
async def run_eventsub(broadcaster_id: str, access_token: str) -> None:
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(EVENTSUB_WS_URL) as ws:
|
||||
logger.info("EventSub connected")
|
||||
async for raw in ws:
|
||||
msg = json.loads(raw)
|
||||
msg_type = msg.get("metadata", {}).get("message_type")
|
||||
|
||||
if msg_type == "session_welcome":
|
||||
session_id = msg["payload"]["session"]["id"]
|
||||
token = await _subscribe_all(session_id, broadcaster_id, access_token)
|
||||
if token:
|
||||
access_token = token
|
||||
|
||||
elif msg_type == "notification":
|
||||
await _handle_notification(msg["payload"])
|
||||
|
||||
elif msg_type == "session_reconnect":
|
||||
reconnect_url = msg["payload"]["session"]["reconnect_url"]
|
||||
logger.info("EventSub reconnect requested")
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("EventSub task cancelled")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"EventSub error: {e}, reconnecting in 10s")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
async def _subscribe_all(session_id: str, broadcaster_id: str, access_token: str) -> str | None:
|
||||
transport = {"method": "websocket", "session_id": session_id}
|
||||
condition = {"broadcaster_user_id": broadcaster_id}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Client-Id": TWITCH_CLIENT_ID,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for sub_type, version, condition_fn in SUBSCRIPTIONS:
|
||||
condition = condition_fn(broadcaster_id)
|
||||
r = await client.post(
|
||||
"https://api.twitch.tv/helix/eventsub/subscriptions",
|
||||
headers=headers,
|
||||
json={"type": sub_type, "version": version, "condition": condition, "transport": transport},
|
||||
)
|
||||
if r.status_code == 401:
|
||||
new_token = await _refresh_token(access_token)
|
||||
if new_token:
|
||||
headers["Authorization"] = f"Bearer {new_token}"
|
||||
await client.post(
|
||||
"https://api.twitch.tv/helix/eventsub/subscriptions",
|
||||
headers=headers,
|
||||
json={"type": sub_type, "version": version, "condition": condition, "transport": transport},
|
||||
)
|
||||
return new_token
|
||||
elif r.status_code not in (200, 202, 409):
|
||||
logger.warning(f"Failed to subscribe to {sub_type}: {r.status_code} {r.text}")
|
||||
return None
|
||||
|
||||
|
||||
async def _refresh_token(old_token: str) -> str | None:
|
||||
from app.database import engine
|
||||
from app.models import User
|
||||
from sqlmodel import Session, select
|
||||
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(User).where(User.access_token == old_token)).first()
|
||||
if not user:
|
||||
return None
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post("https://id.twitch.tv/oauth2/token", data={
|
||||
"client_id": TWITCH_CLIENT_ID,
|
||||
"client_secret": TWITCH_CLIENT_SECRET,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": user.refresh_token,
|
||||
})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
data = r.json()
|
||||
user.access_token = data["access_token"]
|
||||
user.refresh_token = data.get("refresh_token", user.refresh_token)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return user.access_token
|
||||
|
||||
|
||||
async def _handle_notification(payload: dict) -> None:
|
||||
sub_type = payload["subscription"]["type"]
|
||||
event = payload["event"]
|
||||
username = event.get("user_name") or event.get("user_login")
|
||||
|
||||
if sub_type in ("channel.subscribe", "channel.subscription.message"):
|
||||
await dispatch_event("sub", username=username, event_detail=event.get("tier", "1000"))
|
||||
|
||||
elif sub_type == "channel.subscription.gift":
|
||||
await dispatch_event("gift_sub", username=username, event_detail=event.get("tier", "1000"))
|
||||
|
||||
elif sub_type == "channel.cheer":
|
||||
await dispatch_event("bits", username=username, bits_amount=event.get("bits", 0))
|
||||
|
||||
elif sub_type == "channel.channel_points_custom_reward_redemption.add":
|
||||
reward_id = event.get("reward", {}).get("id")
|
||||
await dispatch_event("channel_points", username=username, event_detail=reward_id)
|
||||
|
||||
elif sub_type == "channel.follow":
|
||||
await dispatch_event("follow", username=username)
|
||||
|
||||
elif sub_type == "channel.raid":
|
||||
raider = event.get("from_broadcaster_user_name") or event.get("from_broadcaster_user_login")
|
||||
viewers = str(event.get("viewers", ""))
|
||||
await dispatch_event("raid", username=raider, event_detail=viewers)
|
||||
60
app/main.py
Normal file
60
app/main.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.database import create_db, get_admin_user
|
||||
from app.routers import actions, auth, dashboard, layout, media, overlay, settings
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_eventsub_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
def get_eventsub_task() -> Optional[asyncio.Task]:
|
||||
return _eventsub_task
|
||||
|
||||
|
||||
async def start_eventsub() -> None:
|
||||
global _eventsub_task
|
||||
from app.eventsub import run_eventsub
|
||||
|
||||
admin = get_admin_user()
|
||||
if not admin:
|
||||
return
|
||||
|
||||
if _eventsub_task and not _eventsub_task.done():
|
||||
_eventsub_task.cancel()
|
||||
try:
|
||||
await _eventsub_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
_eventsub_task = asyncio.create_task(run_eventsub(admin.broadcaster_id, admin.access_token))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_db()
|
||||
await start_eventsub()
|
||||
yield
|
||||
if _eventsub_task and not _eventsub_task.done():
|
||||
_eventsub_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="BashyOverlay", lifespan=lifespan)
|
||||
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SECRET_KEY", "dev-secret-change-me"))
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(overlay.router)
|
||||
app.include_router(actions.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(layout.router)
|
||||
app.include_router(settings.router)
|
||||
79
app/models.py
Normal file
79
app/models.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
twitch_id: str = Field(unique=True, index=True)
|
||||
twitch_login: str
|
||||
twitch_display_name: str
|
||||
broadcaster_id: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
is_admin: bool = False
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class MediaFile(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
filename: str
|
||||
original_name: str
|
||||
file_type: str # "audio" | "video"
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
actions: List["EventAction"] = Relationship(back_populates="media_file")
|
||||
|
||||
|
||||
class EventAction(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
event_type: str # "sub" | "gift_sub" | "bits" | "channel_points" | "command"
|
||||
event_detail: Optional[str] = None # command name, reward ID, sub tier, bits minimum
|
||||
action_type: str # "sound" | "video" | "alert"
|
||||
media_file_id: Optional[int] = Field(default=None, foreign_key="mediafile.id")
|
||||
alert_text: Optional[str] = None
|
||||
cooldown_seconds: int = 0
|
||||
enabled: bool = True
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
media_file: Optional["MediaFile"] = Relationship(back_populates="actions")
|
||||
|
||||
|
||||
class ActivityLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
event_type: str
|
||||
username: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
action_taken: Optional[str] = None
|
||||
triggered_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class Settings(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
twitch_channel: str = ""
|
||||
gap_silence_threshold: int = 30
|
||||
gap_scale_end: int = 120
|
||||
gap_pitch_scale_end: int = 300
|
||||
custom_css: str = ""
|
||||
custom_js: str = ""
|
||||
|
||||
|
||||
class OverlayLayout(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
# Chat widget — position/size as % of canvas
|
||||
chat_left: float = 72.0
|
||||
chat_top: float = 5.0
|
||||
chat_width: float = 27.0
|
||||
chat_height: float = 88.0
|
||||
chat_font_size: int = 14
|
||||
chat_bg_opacity: float = 0.55
|
||||
chat_text_color: str = "#ffffff"
|
||||
chat_max_messages: int = 20
|
||||
# Alert widget — position as % of canvas (centered via transform)
|
||||
alert_left: float = 50.0
|
||||
alert_top: float = 40.0
|
||||
alert_font_size: int = 24
|
||||
alert_bg_opacity: float = 0.8
|
||||
alert_text_color: str = "#ffffff"
|
||||
alert_duration: int = 4
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
140
app/routers/actions.py
Normal file
140
app/routers/actions.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import require_user
|
||||
from app.database import get_session
|
||||
from app.models import ActivityLog, EventAction, MediaFile
|
||||
from app.templates import templates
|
||||
from app.ws import manager
|
||||
|
||||
router = APIRouter(prefix="/actions")
|
||||
|
||||
EVENT_TYPES = ["sub", "gift_sub", "bits", "channel_points", "command", "follow", "raid"]
|
||||
ACTION_TYPES = ["sound", "video", "alert"]
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def actions_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
|
||||
actions = session.exec(select(EventAction)).all()
|
||||
media_files = session.exec(select(MediaFile)).all()
|
||||
return templates.TemplateResponse("actions.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"actions": actions,
|
||||
"media_files": media_files,
|
||||
"event_types": EVENT_TYPES,
|
||||
"action_types": ACTION_TYPES,
|
||||
})
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def create_action(
|
||||
request: Request,
|
||||
event_type: str = Form(...),
|
||||
event_detail: Optional[str] = Form(default=None),
|
||||
action_type: str = Form(...),
|
||||
media_file_id: Optional[int] = Form(default=None),
|
||||
alert_text: Optional[str] = Form(default=None),
|
||||
cooldown_seconds: int = Form(default=0),
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
action = EventAction(
|
||||
event_type=event_type,
|
||||
event_detail=event_detail.strip() if event_detail and event_detail.strip() else None,
|
||||
action_type=action_type,
|
||||
media_file_id=media_file_id or None,
|
||||
alert_text=alert_text.strip() if alert_text and alert_text.strip() else None,
|
||||
cooldown_seconds=max(0, cooldown_seconds),
|
||||
)
|
||||
session.add(action)
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
|
||||
media_files = session.exec(select(MediaFile)).all()
|
||||
return templates.TemplateResponse("partials/action_row.html", {
|
||||
"request": request,
|
||||
"action": action,
|
||||
"media_files": media_files,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{action_id}/test", response_class=HTMLResponse)
|
||||
async def test_action(
|
||||
request: Request,
|
||||
action_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
action = session.get(EventAction, action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
media_url = None
|
||||
if action.media_file_id:
|
||||
media = session.get(MediaFile, action.media_file_id)
|
||||
if media:
|
||||
media_url = f"/static/media/{media.filename}"
|
||||
|
||||
await manager.broadcast({
|
||||
"type": "execute",
|
||||
"action_type": action.action_type,
|
||||
"media_url": media_url,
|
||||
"alert_text": action.alert_text or "Test!",
|
||||
})
|
||||
|
||||
session.add(ActivityLog(
|
||||
event_type="test",
|
||||
username=user.twitch_display_name,
|
||||
detail=f"{action.event_type}:{action.event_detail or 'any'}",
|
||||
action_taken=f"{action.action_type}: {media_url or action.alert_text or ''}",
|
||||
))
|
||||
session.commit()
|
||||
|
||||
media_files = session.exec(select(MediaFile)).all()
|
||||
return templates.TemplateResponse("partials/action_row.html", {
|
||||
"request": request,
|
||||
"action": action,
|
||||
"media_files": media_files,
|
||||
"just_tested": True,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/{action_id}", response_class=HTMLResponse)
|
||||
async def delete_action(
|
||||
action_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
action = session.get(EventAction, action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404)
|
||||
session.delete(action)
|
||||
session.commit()
|
||||
return HTMLResponse("")
|
||||
|
||||
|
||||
@router.post("/{action_id}/toggle", response_class=HTMLResponse)
|
||||
async def toggle_action(
|
||||
request: Request,
|
||||
action_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
action = session.get(EventAction, action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404)
|
||||
action.enabled = not action.enabled
|
||||
session.add(action)
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
|
||||
media_files = session.exec(select(MediaFile)).all()
|
||||
return templates.TemplateResponse("partials/action_row.html", {
|
||||
"request": request,
|
||||
"action": action,
|
||||
"media_files": media_files,
|
||||
})
|
||||
75
app/routers/auth.py
Normal file
75
app/routers/auth.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import TWITCH_AUTH_URL, exchange_code, get_twitch_user, get_current_user
|
||||
from app.database import get_session
|
||||
from app.models import Settings, User
|
||||
from app.templates import templates
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, user=Depends(get_current_user)):
|
||||
if user:
|
||||
return RedirectResponse(url="/")
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/twitch")
|
||||
async def twitch_redirect():
|
||||
return RedirectResponse(url=TWITCH_AUTH_URL)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def twitch_callback(request: Request, code: str, session: Session = Depends(get_session)):
|
||||
try:
|
||||
tokens = await exchange_code(code)
|
||||
twitch_user = await get_twitch_user(tokens["access_token"])
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="OAuth failed")
|
||||
|
||||
user = session.exec(select(User).where(User.twitch_id == twitch_user["id"])).first()
|
||||
is_first = user is None
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
twitch_id=twitch_user["id"],
|
||||
twitch_login=twitch_user["login"],
|
||||
twitch_display_name=twitch_user["display_name"],
|
||||
broadcaster_id=twitch_user["id"],
|
||||
access_token=tokens["access_token"],
|
||||
refresh_token=tokens.get("refresh_token", ""),
|
||||
is_admin=True,
|
||||
)
|
||||
else:
|
||||
user.access_token = tokens["access_token"]
|
||||
user.refresh_token = tokens.get("refresh_token", user.refresh_token)
|
||||
user.twitch_display_name = twitch_user["display_name"]
|
||||
|
||||
session.add(user)
|
||||
|
||||
if is_first:
|
||||
settings = session.exec(select(Settings)).first()
|
||||
if not settings:
|
||||
settings = Settings()
|
||||
settings.twitch_channel = twitch_user["login"]
|
||||
session.add(settings)
|
||||
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
request.session["user_id"] = user.id
|
||||
|
||||
if is_first:
|
||||
from app.main import start_eventsub
|
||||
import asyncio
|
||||
asyncio.create_task(start_eventsub())
|
||||
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/auth/login")
|
||||
28
app/routers/dashboard.py
Normal file
28
app/routers/dashboard.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlmodel import Session, select, desc
|
||||
|
||||
from app.auth import require_user
|
||||
from app.database import get_session
|
||||
from app.models import ActivityLog
|
||||
from app.templates import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
|
||||
from app.main import get_eventsub_task
|
||||
task = get_eventsub_task()
|
||||
eventsub_connected = task is not None and not task.done()
|
||||
|
||||
logs = session.exec(
|
||||
select(ActivityLog).order_by(desc(ActivityLog.triggered_at)).limit(50)
|
||||
).all()
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"eventsub_connected": eventsub_connected,
|
||||
"logs": logs,
|
||||
})
|
||||
91
app/routers/layout.py
Normal file
91
app/routers/layout.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import require_user
|
||||
from app.database import get_session
|
||||
from app.models import OverlayLayout
|
||||
from app.templates import templates
|
||||
|
||||
router = APIRouter(prefix="/layout")
|
||||
|
||||
|
||||
def _get_or_create(session: Session) -> OverlayLayout:
|
||||
layout = session.exec(select(OverlayLayout)).first()
|
||||
if not layout:
|
||||
layout = OverlayLayout()
|
||||
session.add(layout)
|
||||
session.commit()
|
||||
session.refresh(layout)
|
||||
return layout
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def layout_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
|
||||
layout = _get_or_create(session)
|
||||
return templates.TemplateResponse("layout.html", {"request": request, "user": user, "layout": layout})
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def save_styles(
|
||||
request: Request,
|
||||
chat_font_size: int = Form(default=14),
|
||||
chat_bg_opacity: float = Form(default=0.55),
|
||||
chat_text_color: str = Form(default="#ffffff"),
|
||||
chat_max_messages: int = Form(default=20),
|
||||
alert_font_size: int = Form(default=24),
|
||||
alert_bg_opacity: float = Form(default=0.8),
|
||||
alert_text_color: str = Form(default="#ffffff"),
|
||||
alert_duration: int = Form(default=4),
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
layout = _get_or_create(session)
|
||||
layout.chat_font_size = max(8, min(48, chat_font_size))
|
||||
layout.chat_bg_opacity = max(0.0, min(1.0, chat_bg_opacity))
|
||||
layout.chat_text_color = chat_text_color
|
||||
layout.chat_max_messages = max(1, min(50, chat_max_messages))
|
||||
layout.alert_font_size = max(8, min(72, alert_font_size))
|
||||
layout.alert_bg_opacity = max(0.0, min(1.0, alert_bg_opacity))
|
||||
layout.alert_text_color = alert_text_color
|
||||
layout.alert_duration = max(1, min(30, alert_duration))
|
||||
session.add(layout)
|
||||
session.commit()
|
||||
session.refresh(layout)
|
||||
return templates.TemplateResponse("layout.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"layout": layout,
|
||||
"saved": True,
|
||||
})
|
||||
|
||||
|
||||
class PositionUpdate(BaseModel):
|
||||
widget: str
|
||||
left: float
|
||||
top: float
|
||||
width: Optional[float] = None
|
||||
height: Optional[float] = None
|
||||
|
||||
|
||||
@router.post("/positions")
|
||||
async def save_positions(
|
||||
data: PositionUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
layout = _get_or_create(session)
|
||||
if data.widget == "chat":
|
||||
layout.chat_left = round(max(0.0, min(95.0, data.left)), 2)
|
||||
layout.chat_top = round(max(0.0, min(95.0, data.top)), 2)
|
||||
if data.width is not None: layout.chat_width = round(max(5.0, min(100.0, data.width)), 2)
|
||||
if data.height is not None: layout.chat_height = round(max(5.0, min(100.0, data.height)), 2)
|
||||
elif data.widget == "alert":
|
||||
layout.alert_left = round(max(0.0, min(100.0, data.left)), 2)
|
||||
layout.alert_top = round(max(0.0, min(100.0, data.top)), 2)
|
||||
session.add(layout)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
79
app/routers/media.py
Normal file
79
app/routers/media.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import require_user
|
||||
from app.database import get_session
|
||||
from app.models import MediaFile
|
||||
from app.templates import templates
|
||||
|
||||
router = APIRouter(prefix="/media")
|
||||
|
||||
MEDIA_DIR = "static/media"
|
||||
AUDIO_EXTENSIONS = {".mp3", ".ogg", ".wav", ".m4a"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov"}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def media_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
|
||||
files = session.exec(select(MediaFile)).all()
|
||||
return templates.TemplateResponse("media.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"files": files,
|
||||
})
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def upload_file(
|
||||
request: Request,
|
||||
file: UploadFile,
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext in AUDIO_EXTENSIONS:
|
||||
file_type = "audio"
|
||||
elif ext in VIDEO_EXTENSIONS:
|
||||
file_type = "video"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file type")
|
||||
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, filename)
|
||||
|
||||
content = await file.read()
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
media = MediaFile(filename=filename, original_name=file.filename, file_type=file_type)
|
||||
session.add(media)
|
||||
session.commit()
|
||||
session.refresh(media)
|
||||
|
||||
return templates.TemplateResponse("partials/media_item.html", {
|
||||
"request": request,
|
||||
"file": media,
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/{file_id}", response_class=HTMLResponse)
|
||||
async def delete_file(
|
||||
file_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
media = session.get(MediaFile, file_id)
|
||||
if not media:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
path = os.path.join(MEDIA_DIR, media.filename)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
session.delete(media)
|
||||
session.commit()
|
||||
return HTMLResponse("")
|
||||
57
app/routers/overlay.py
Normal file
57
app/routers/overlay.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.database import get_layout, get_settings
|
||||
from app.dispatch import dispatch_event
|
||||
from app.templates import templates
|
||||
from app.ws import manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/overlay", response_class=HTMLResponse)
|
||||
async def overlay_page(request: Request):
|
||||
settings = get_settings()
|
||||
layout = get_layout()
|
||||
return templates.TemplateResponse("overlay.html", {
|
||||
"request": request,
|
||||
"channel": settings.twitch_channel,
|
||||
"gap_silence": settings.gap_silence_threshold,
|
||||
"gap_scale_end": settings.gap_scale_end,
|
||||
"gap_pitch_end": settings.gap_pitch_scale_end,
|
||||
"layout": layout,
|
||||
"custom_css": settings.custom_css,
|
||||
"custom_js": settings.custom_js,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/overlay/preview", response_class=HTMLResponse)
|
||||
async def overlay_preview(request: Request):
|
||||
settings = get_settings()
|
||||
layout = get_layout()
|
||||
return templates.TemplateResponse("overlay_preview.html", {
|
||||
"request": request,
|
||||
"layout": layout,
|
||||
"custom_css": settings.custom_css,
|
||||
"custom_js": settings.custom_js,
|
||||
"alert_duration_ms": layout.alert_duration * 1000,
|
||||
})
|
||||
|
||||
|
||||
@router.websocket("/ws/overlay")
|
||||
async def overlay_ws(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
if data.get("type") == "command":
|
||||
await dispatch_event(
|
||||
"command",
|
||||
username=data.get("user"),
|
||||
event_detail=data.get("name"),
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
except Exception:
|
||||
manager.disconnect(websocket)
|
||||
64
app/routers/settings.py
Normal file
64
app/routers/settings.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import require_user
|
||||
from app.database import get_session
|
||||
from app.models import Settings
|
||||
from app.templates import templates
|
||||
|
||||
router = APIRouter(prefix="/settings")
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request, session: Session = Depends(get_session), user=Depends(require_user)):
|
||||
settings = session.exec(select(Settings)).first() or Settings()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"settings": settings,
|
||||
"overlay_url": str(request.base_url) + "overlay",
|
||||
})
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
async def save_settings(
|
||||
request: Request,
|
||||
twitch_channel: str = Form(...),
|
||||
gap_silence_threshold: int = Form(default=30),
|
||||
gap_scale_end: int = Form(default=120),
|
||||
gap_pitch_scale_end: int = Form(default=300),
|
||||
custom_css: str = Form(default=""),
|
||||
custom_js: str = Form(default=""),
|
||||
session: Session = Depends(get_session),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
settings = session.exec(select(Settings)).first()
|
||||
if not settings:
|
||||
settings = Settings()
|
||||
|
||||
settings.twitch_channel = twitch_channel.strip().lstrip("#")
|
||||
settings.gap_silence_threshold = max(0, gap_silence_threshold)
|
||||
settings.gap_scale_end = max(settings.gap_silence_threshold + 1, gap_scale_end)
|
||||
settings.gap_pitch_scale_end = max(settings.gap_scale_end + 1, gap_pitch_scale_end)
|
||||
settings.custom_css = custom_css
|
||||
settings.custom_js = custom_js
|
||||
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"settings": settings,
|
||||
"overlay_url": str(request.base_url) + "overlay",
|
||||
"saved": True,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/reconnect")
|
||||
async def reconnect_eventsub(user=Depends(require_user)):
|
||||
from app.main import start_eventsub
|
||||
import asyncio
|
||||
asyncio.create_task(start_eventsub())
|
||||
return RedirectResponse(url="/settings", status_code=303)
|
||||
3
app/templates.py
Normal file
3
app/templates.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
28
app/ws.py
Normal file
28
app/ws.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active: list[WebSocket] = []
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self.active.append(ws)
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
self.active.discard(ws) if hasattr(self.active, "discard") else None
|
||||
if ws in self.active:
|
||||
self.active.remove(ws)
|
||||
|
||||
async def broadcast(self, message: dict) -> None:
|
||||
dead = []
|
||||
for ws in self.active:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
72
deploy.sh
Executable file
72
deploy.sh
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[BashyOverlay]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[BashyOverlay]${NC} $1"; }
|
||||
error() { echo -e "${RED}[BashyOverlay]${NC} $1"; exit 1; }
|
||||
|
||||
# ── Python version check ──────────────────────────────────────────────────────
|
||||
PYTHON=$(command -v python3 || command -v python || error "Python not found")
|
||||
PY_VER=$($PYTHON -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
PY_MAJOR=$($PYTHON -c 'import sys; print(sys.version_info.major)')
|
||||
PY_MINOR=$($PYTHON -c 'import sys; print(sys.version_info.minor)')
|
||||
|
||||
if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 10 ]; }; then
|
||||
error "Python 3.10+ required (found $PY_VER)"
|
||||
fi
|
||||
info "Python $PY_VER OK"
|
||||
|
||||
# ── Virtual environment ───────────────────────────────────────────────────────
|
||||
if [ ! -d ".venv" ]; then
|
||||
info "Creating virtual environment..."
|
||||
$PYTHON -m venv .venv
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
info "Virtual environment active"
|
||||
|
||||
# ── Dependencies ──────────────────────────────────────────────────────────────
|
||||
info "Installing dependencies..."
|
||||
pip install -q --upgrade pip
|
||||
pip install -q -r requirements.txt
|
||||
info "Dependencies installed"
|
||||
|
||||
# ── .env ──────────────────────────────────────────────────────────────────────
|
||||
if [ ! -f ".env" ]; then
|
||||
cp .env.example .env
|
||||
warn ".env created from .env.example"
|
||||
warn "Edit .env with your Twitch credentials before continuing."
|
||||
warn ""
|
||||
warn " TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET:"
|
||||
warn " Register an app at https://dev.twitch.tv/console/apps"
|
||||
warn " Set OAuth Redirect URL to: http://localhost:8000/auth/callback"
|
||||
warn ""
|
||||
echo -n "Have you filled in .env? [y/N] "
|
||||
read -r answer
|
||||
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
|
||||
warn "Edit .env and run deploy.sh again."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Media directory ───────────────────────────────────────────────────────────
|
||||
mkdir -p static/media
|
||||
info "Media directory ready"
|
||||
|
||||
# ── Launch ────────────────────────────────────────────────────────────────────
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
PORT="${PORT:-8000}"
|
||||
|
||||
info "Starting BashyOverlay at http://localhost:${PORT}"
|
||||
info "Open that URL in your browser to log in with Twitch."
|
||||
info "OBS browser source URL: http://localhost:${PORT}/overlay"
|
||||
info ""
|
||||
info "Press Ctrl+C to stop."
|
||||
echo ""
|
||||
|
||||
exec uvicorn app.main:app --host "$HOST" --port "$PORT" --reload
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
sqlmodel>=0.0.21
|
||||
jinja2>=3.1.4
|
||||
python-dotenv>=1.0.1
|
||||
httpx>=0.27.0
|
||||
python-multipart>=0.0.9
|
||||
websockets>=12.0
|
||||
itsdangerous>=2.1.0
|
||||
0
static/media/.gitkeep
Normal file
0
static/media/.gitkeep
Normal file
74
static/overlay.css
Normal file
74
static/overlay.css
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#chat {
|
||||
position: fixed;
|
||||
left: var(--chat-left, 72%);
|
||||
top: var(--chat-top, 5%);
|
||||
width: var(--chat-width, 27%);
|
||||
height: var(--chat-height, 88%);
|
||||
font-size: var(--chat-font-size, 14px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
background: rgba(0, 0, 0, var(--chat-bg-opacity, 0.55));
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
color: var(--chat-text-color, #fff);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
animation: fadein 0.2s ease;
|
||||
}
|
||||
|
||||
.uname { font-weight: bold; }
|
||||
|
||||
#alert {
|
||||
position: fixed;
|
||||
left: var(--alert-left, 50%);
|
||||
top: var(--alert-top, 40%);
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, var(--alert-bg-opacity, 0.8));
|
||||
color: var(--alert-text-color, #fff);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 12px;
|
||||
font-size: var(--alert-font-size, 24px);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
max-width: 80vw;
|
||||
animation: popin 0.3s ease;
|
||||
}
|
||||
|
||||
#alert-video-wrap {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#alert-video {
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes popin {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
87
static/style.css
Normal file
87
static/style.css
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/* Management UI */
|
||||
.login-page { display: flex; align-items: center; justify-content: center; min-height: 80vh; }
|
||||
.login-card { text-align: center; max-width: 360px; }
|
||||
.twitch-btn { background: #9146ff; border-color: #9146ff; color: #fff; display: inline-block; }
|
||||
.twitch-btn:hover { background: #772ce8; border-color: #772ce8; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.status-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; padding: 0.5rem 1rem; border-radius: var(--pico-border-radius); background: var(--pico-card-background-color); }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.connected { background: #2ecc71; }
|
||||
.status-dot.disconnected { background: #e74c3c; }
|
||||
|
||||
.action-row { display: flex; align-items: center; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid var(--pico-table-border-color); gap: 1rem; }
|
||||
.action-info { flex: 1; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.action-controls { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
|
||||
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.media-item { background: var(--pico-card-background-color); border-radius: var(--pico-border-radius); padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.media-info { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.media-type-badge { font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 999px; font-weight: bold; text-transform: uppercase; }
|
||||
.media-type-badge.audio { background: #2980b9; color: #fff; }
|
||||
.media-type-badge.video { background: #8e44ad; color: #fff; }
|
||||
.media-item audio, .media-item video { width: 100%; }
|
||||
|
||||
.small-btn { padding: 0.25rem 0.6rem; font-size: 0.8rem; margin-bottom: 0; }
|
||||
.tested-badge { color: #2ecc71; font-size: 0.85rem; animation: fadeout 2s ease 1s forwards; }
|
||||
@keyframes fadeout { from { opacity: 1; } to { opacity: 0; } }
|
||||
.muted { color: var(--pico-muted-color); }
|
||||
section { margin-bottom: 2rem; }
|
||||
.url-input { font-family: monospace; font-size: 0.85em; }
|
||||
.code-textarea { font-family: 'Fira Code', 'Consolas', 'Monaco', monospace; font-size: 0.82rem; resize: vertical; }
|
||||
.notice-success { background: #1a3a1a; border: 1px solid #2ecc71; border-radius: var(--pico-border-radius); padding: 0.75rem 1rem; margin-bottom: 1rem; }
|
||||
|
||||
/* Layout editor */
|
||||
.editor-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pico-table-border-color);
|
||||
background: #000;
|
||||
}
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
border: 2px dashed rgba(145, 70, 255, 0.7);
|
||||
border-radius: 4px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
.drag-handle:hover { border-color: rgba(145, 70, 255, 1); background: rgba(145,70,255,0.08); }
|
||||
.handle-label {
|
||||
font-size: 0.6rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(145, 70, 255, 0.9);
|
||||
padding: 2px 5px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
border-radius: 2px;
|
||||
width: fit-content;
|
||||
}
|
||||
.resize-corner {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: rgba(145, 70, 255, 0.8);
|
||||
border-top-left-radius: 4px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
.canvas-footer { display: flex; justify-content: space-between; align-items: center; margin: 0.4rem 0 1rem; }
|
||||
.style-form { margin-top: 0.5rem; }
|
||||
.props-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||
@media (max-width: 768px) { .props-grid { grid-template-columns: 1fr; } }
|
||||
.props-row { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.props-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
|
||||
.props-row input[type="number"] { width: 70px; margin-bottom: 0; padding: 0.3rem; }
|
||||
.props-row input[type="range"] { width: 100px; margin-bottom: 0; }
|
||||
.props-row input[type="color"] { width: 48px; height: 36px; padding: 2px; border-radius: 4px; cursor: pointer; margin-bottom: 0; }
|
||||
.props-row span { font-size: 0.75rem; color: var(--pico-muted-color); }
|
||||
71
templates/actions.html
Normal file
71
templates/actions.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Actions — BashyOverlay{% endblock %}
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>Actions</h1>
|
||||
<p>Configure what happens when each event fires.</p>
|
||||
</hgroup>
|
||||
|
||||
<section>
|
||||
<h2>Add action</h2>
|
||||
<form hx-post="/actions"
|
||||
hx-target="#actions-list"
|
||||
hx-swap="beforeend"
|
||||
hx-on::after-request="this.reset()">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Event type
|
||||
<select name="event_type" required>
|
||||
{% for et in event_types %}
|
||||
<option value="{{ et }}">{{ et }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Event detail
|
||||
<input type="text" name="event_detail" placeholder="command name / reward ID / bits min / sub tier">
|
||||
<small>Leave blank to match any. For commands: the name without !. For bits: minimum amount.</small>
|
||||
</label>
|
||||
<label>
|
||||
Action type
|
||||
<select name="action_type" required>
|
||||
{% for at in action_types %}
|
||||
<option value="{{ at }}">{{ at }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Media file <small>(for sound/video)</small>
|
||||
<select name="media_file_id">
|
||||
<option value="">— none —</option>
|
||||
{% for f in media_files %}
|
||||
<option value="{{ f.id }}">[{{ f.file_type }}] {{ f.original_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Alert text <small>(shown on screen)</small>
|
||||
<input type="text" name="alert_text" placeholder="e.g. {{ '{{username}} just subscribed!' }}">
|
||||
</label>
|
||||
<label>
|
||||
Cooldown (seconds)
|
||||
<input type="number" name="cooldown_seconds" value="0" min="0">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Add action</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Configured actions</h2>
|
||||
<div id="actions-list">
|
||||
{% for action in actions %}
|
||||
{% set _ = namespace(media_files=media_files) %}
|
||||
{% include "partials/action_row.html" %}
|
||||
{% endfor %}
|
||||
{% if not actions %}
|
||||
<p><em>No actions configured yet.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
31
templates/base.html
Normal file
31
templates/base.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}BashyOverlay{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.0.6/css/pico.min.css">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="container-fluid">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><strong><a href="/" class="contrast">BashyOverlay</a></strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li><a href="/actions">Actions</a></li>
|
||||
<li><a href="/media">Media</a></li>
|
||||
<li><a href="/layout">Layout</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/auth/logout" class="secondary">Logout</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
50
templates/dashboard.html
Normal file
50
templates/dashboard.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — BashyOverlay{% endblock %}
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome, {{ user.twitch_display_name }}</p>
|
||||
</hgroup>
|
||||
|
||||
<div class="status-bar">
|
||||
<span class="status-dot {% if eventsub_connected %}connected{% else %}disconnected{% endif %}"></span>
|
||||
EventSub: <strong>{% if eventsub_connected %}connected{% else %}disconnected{% endif %}</strong>
|
||||
{% if not eventsub_connected %}
|
||||
<form method="post" action="/settings/reconnect" style="display:inline">
|
||||
<button type="submit" class="outline small-btn">Reconnect</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Recent activity</h2>
|
||||
{% if logs %}
|
||||
<figure>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Event</th>
|
||||
<th>User</th>
|
||||
<th>Detail</th>
|
||||
<th>Action taken</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td><small>{{ log.triggered_at.strftime('%H:%M:%S') }}</small></td>
|
||||
<td><code>{{ log.event_type }}</code></td>
|
||||
<td>{{ log.username or '—' }}</td>
|
||||
<td>{{ log.detail or '—' }}</td>
|
||||
<td>{{ log.action_taken or '<span class="muted">none configured</span>' | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
{% else %}
|
||||
<p><em>No activity yet. Events will appear here as they come in.</em></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
200
templates/layout.html
Normal file
200
templates/layout.html
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Layout Editor — BashyOverlay{% endblock %}
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>Layout Editor</h1>
|
||||
<p>Drag handles to reposition. Resize chat from the bottom-right corner. Changes to styles preview live.</p>
|
||||
</hgroup>
|
||||
|
||||
{% if saved %}
|
||||
<div class="notice-success">Styles saved. Refresh your OBS browser source to apply.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="editor-wrap" id="editor-wrap">
|
||||
<iframe id="preview-frame"
|
||||
src="/overlay/preview"
|
||||
scrolling="no"
|
||||
style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;pointer-events:none;">
|
||||
</iframe>
|
||||
|
||||
<div class="drag-handle handle-chat"
|
||||
id="handle-chat"
|
||||
data-target="chat"
|
||||
data-left="{{ layout.chat_left }}"
|
||||
data-top="{{ layout.chat_top }}"
|
||||
data-width="{{ layout.chat_width }}"
|
||||
data-height="{{ layout.chat_height }}"
|
||||
style="left:{{ layout.chat_left }}%;top:{{ layout.chat_top }}%;width:{{ layout.chat_width }}%;height:{{ layout.chat_height }}%">
|
||||
<div class="handle-label">Chat</div>
|
||||
<div class="resize-corner"></div>
|
||||
</div>
|
||||
|
||||
<div class="drag-handle handle-alert"
|
||||
id="handle-alert"
|
||||
data-target="alert"
|
||||
data-left="{{ layout.alert_left }}"
|
||||
data-top="{{ layout.alert_top }}"
|
||||
style="left:{{ layout.alert_left }}%;top:{{ layout.alert_top }}%;transform:translate(-50%,-50%)">
|
||||
<div class="handle-label">Alert</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-footer">
|
||||
<small class="muted">Positions save on drag end. Refresh OBS browser source after repositioning.</small>
|
||||
<button type="button" id="preview-alert-btn" class="outline small-btn">▶ Preview alert</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/layout" class="style-form">
|
||||
<div class="props-grid">
|
||||
<fieldset>
|
||||
<legend><strong>Chat</strong></legend>
|
||||
<div class="props-row">
|
||||
<label>
|
||||
Font size (px)
|
||||
<input type="number" name="chat_font_size" value="{{ layout.chat_font_size }}" min="8" max="48">
|
||||
</label>
|
||||
<label>
|
||||
BG opacity
|
||||
<input type="range" name="chat_bg_opacity" value="{{ layout.chat_bg_opacity }}"
|
||||
min="0" max="1" step="0.05"
|
||||
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
|
||||
<span>{{ layout.chat_bg_opacity }}</span>
|
||||
</label>
|
||||
<label>
|
||||
Text color
|
||||
<input type="color" name="chat_text_color" value="{{ layout.chat_text_color }}">
|
||||
</label>
|
||||
<label>
|
||||
Max messages
|
||||
<input type="number" name="chat_max_messages" value="{{ layout.chat_max_messages }}" min="1" max="50">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><strong>Alert</strong></legend>
|
||||
<div class="props-row">
|
||||
<label>
|
||||
Font size (px)
|
||||
<input type="number" name="alert_font_size" value="{{ layout.alert_font_size }}" min="8" max="72">
|
||||
</label>
|
||||
<label>
|
||||
BG opacity
|
||||
<input type="range" name="alert_bg_opacity" value="{{ layout.alert_bg_opacity }}"
|
||||
min="0" max="1" step="0.05"
|
||||
oninput="this.nextElementSibling.textContent = (+this.value).toFixed(2)">
|
||||
<span>{{ layout.alert_bg_opacity }}</span>
|
||||
</label>
|
||||
<label>
|
||||
Text color
|
||||
<input type="color" name="alert_text_color" value="{{ layout.alert_text_color }}">
|
||||
</label>
|
||||
<label>
|
||||
Duration (sec)
|
||||
<input type="number" name="alert_duration" value="{{ layout.alert_duration }}" min="1" max="30">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<button type="submit">Save styles</button>
|
||||
</form>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
|
||||
<script>
|
||||
const wrap = document.getElementById('editor-wrap');
|
||||
const iframe = document.getElementById('preview-frame');
|
||||
|
||||
function iframeDoc() { return iframe.contentDocument || iframe.contentWindow?.document; }
|
||||
|
||||
function iframeVar(cssVar, value) {
|
||||
const doc = iframeDoc();
|
||||
if (doc) doc.documentElement.style.setProperty(cssVar, value);
|
||||
}
|
||||
|
||||
// ── Live style preview ────────────────────────────────────────────────────
|
||||
function bind(name, cssVar, transform = v => v) {
|
||||
const el = document.querySelector(`[name="${name}"]`);
|
||||
if (!el) return;
|
||||
el.addEventListener('input', () => iframeVar(cssVar, transform(el.value)));
|
||||
}
|
||||
|
||||
bind('chat_font_size', '--chat-font-size', v => v + 'px');
|
||||
bind('chat_bg_opacity', '--chat-bg-opacity');
|
||||
bind('chat_text_color', '--chat-text-color');
|
||||
bind('alert_font_size', '--alert-font-size', v => v + 'px');
|
||||
bind('alert_bg_opacity', '--alert-bg-opacity');
|
||||
bind('alert_text_color', '--alert-text-color');
|
||||
|
||||
// ── Preview alert button ──────────────────────────────────────────────────
|
||||
document.getElementById('preview-alert-btn').addEventListener('click', () => {
|
||||
iframe.contentWindow?.postMessage({ type: 'preview-alert' }, '*');
|
||||
});
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────
|
||||
interact('.drag-handle').draggable({
|
||||
listeners: {
|
||||
move(event) {
|
||||
const el = event.target;
|
||||
const ww = wrap.offsetWidth;
|
||||
const wh = wrap.offsetHeight;
|
||||
let left = parseFloat(el.dataset.left || 0);
|
||||
let top = parseFloat(el.dataset.top || 0);
|
||||
left = Math.max(0, Math.min(95, left + event.dx / ww * 100));
|
||||
top = Math.max(0, Math.min(95, top + event.dy / wh * 100));
|
||||
el.style.left = left + '%';
|
||||
el.style.top = top + '%';
|
||||
el.dataset.left = left;
|
||||
el.dataset.top = top;
|
||||
const t = el.dataset.target;
|
||||
iframeVar(`--${t}-left`, left + '%');
|
||||
iframeVar(`--${t}-top`, top + '%');
|
||||
},
|
||||
end(event) {
|
||||
const el = event.target;
|
||||
savePos(el.dataset.target,
|
||||
parseFloat(el.dataset.left),
|
||||
parseFloat(el.dataset.top),
|
||||
parseFloat(el.dataset.width || 0),
|
||||
parseFloat(el.dataset.height || 0));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Resize (chat only) ────────────────────────────────────────────────────
|
||||
interact('#handle-chat').resizable({
|
||||
edges: { right: true, bottom: true },
|
||||
modifiers: [interact.modifiers.restrictSize({ min: { width: 30, height: 30 } })],
|
||||
listeners: {
|
||||
move(event) {
|
||||
const el = event.target;
|
||||
const ww = wrap.offsetWidth;
|
||||
const wh = wrap.offsetHeight;
|
||||
const w = Math.max(5, Math.min(100, event.rect.width / ww * 100));
|
||||
const h = Math.max(5, Math.min(100, event.rect.height / wh * 100));
|
||||
el.style.width = w + '%';
|
||||
el.style.height = h + '%';
|
||||
el.dataset.width = w;
|
||||
el.dataset.height = h;
|
||||
iframeVar('--chat-width', w + '%');
|
||||
iframeVar('--chat-height', h + '%');
|
||||
},
|
||||
end(event) {
|
||||
const el = event.target;
|
||||
savePos('chat',
|
||||
parseFloat(el.dataset.left),
|
||||
parseFloat(el.dataset.top),
|
||||
parseFloat(el.dataset.width),
|
||||
parseFloat(el.dataset.height));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function savePos(target, left, top, width, height) {
|
||||
await fetch('/layout/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ widget: target, left, top, width, height })
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
19
templates/login.html
Normal file
19
templates/login.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — BashyOverlay</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.0.6/css/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container login-page">
|
||||
<article class="login-card">
|
||||
<h1>BashyOverlay</h1>
|
||||
<p>Twitch stream event platform.</p>
|
||||
<a href="/auth/twitch" role="button" class="twitch-btn">Login with Twitch</a>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
35
templates/media.html
Normal file
35
templates/media.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Media — BashyOverlay{% endblock %}
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>Media</h1>
|
||||
<p>Upload sounds and videos for your actions.</p>
|
||||
</hgroup>
|
||||
|
||||
<section>
|
||||
<h2>Upload</h2>
|
||||
<form hx-post="/media"
|
||||
hx-target="#media-list"
|
||||
hx-swap="beforeend"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-on::after-request="this.reset()">
|
||||
<label>
|
||||
File <small>(mp3, ogg, wav, m4a, mp4, webm)</small>
|
||||
<input type="file" name="file" accept="audio/*,video/*" required>
|
||||
</label>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Library</h2>
|
||||
<div id="media-list" class="media-grid">
|
||||
{% for file in files %}
|
||||
{% include "partials/media_item.html" %}
|
||||
{% endfor %}
|
||||
{% if not files %}
|
||||
<p><em>No files uploaded yet.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
162
templates/overlay.html
Normal file
162
templates/overlay.html
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Overlay</title>
|
||||
<link rel="stylesheet" href="/static/overlay.css">
|
||||
{% if custom_css %}<style>{{ custom_css | safe }}</style>{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--chat-left: {{ layout.chat_left }}%;
|
||||
--chat-top: {{ layout.chat_top }}%;
|
||||
--chat-width: {{ layout.chat_width }}%;
|
||||
--chat-height: {{ layout.chat_height }}%;
|
||||
--chat-font-size: {{ layout.chat_font_size }}px;
|
||||
--chat-bg-opacity: {{ layout.chat_bg_opacity }};
|
||||
--chat-text-color: {{ layout.chat_text_color }};
|
||||
--alert-left: {{ layout.alert_left }}%;
|
||||
--alert-top: {{ layout.alert_top }}%;
|
||||
--alert-font-size: {{ layout.alert_font_size }}px;
|
||||
--alert-bg-opacity: {{ layout.alert_bg_opacity }};
|
||||
--alert-text-color: {{ layout.alert_text_color }};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="alert-video-wrap" style="display:none">
|
||||
<video id="alert-video" playsinline></video>
|
||||
</div>
|
||||
<div id="alert" style="display:none"></div>
|
||||
<div id="chat"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/tmi.js@1.8.5/build/tmi.min.js"></script>
|
||||
<script>
|
||||
const CHANNEL = {{ channel | tojson }};
|
||||
const GAP_SIL = {{ gap_silence }};
|
||||
const GAP_VOL = {{ gap_scale_end }};
|
||||
const GAP_PITCH = {{ gap_pitch_end }};
|
||||
const MAX_MSG = {{ layout.chat_max_messages }};
|
||||
const ALERT_DURATION = {{ layout.alert_duration * 1000 }};
|
||||
|
||||
let lastMsgTime = Date.now();
|
||||
let alertQueue = [];
|
||||
let alertBusy = false;
|
||||
|
||||
// ── WebSocket to backend ──────────────────────────────────────────────
|
||||
let ws;
|
||||
function connectWS() {
|
||||
ws = new WebSocket(`ws://${location.host}/ws/overlay`);
|
||||
ws.onmessage = e => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'execute') {
|
||||
document.dispatchEvent(new CustomEvent(
|
||||
`bashyoverlay:${msg.event_type || 'event'}`,
|
||||
{ detail: msg }
|
||||
));
|
||||
enqueueAlert(msg);
|
||||
}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWS, 3000);
|
||||
}
|
||||
connectWS();
|
||||
|
||||
// ── Gap audio (Web Audio API — no file needed) ────────────────────────
|
||||
function playGapBeep(gapSec) {
|
||||
if (gapSec < GAP_SIL) return;
|
||||
const vol = Math.min(1, (gapSec - GAP_SIL) / Math.max(1, GAP_VOL - GAP_SIL));
|
||||
const pitch = 1 + Math.min(1, Math.max(0, (gapSec - GAP_VOL) / Math.max(1, GAP_PITCH - GAP_VOL)));
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 440 * pitch;
|
||||
gain.gain.value = vol * 0.35;
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.25);
|
||||
setTimeout(() => ctx.close(), 500);
|
||||
}
|
||||
|
||||
// ── tmi.js chat ───────────────────────────────────────────────────────
|
||||
const client = new tmi.Client({ channels: [CHANNEL] });
|
||||
client.connect().catch(console.error);
|
||||
|
||||
client.on('message', (channel, tags, message, self) => {
|
||||
const now = Date.now();
|
||||
playGapBeep((now - lastMsgTime) / 1000);
|
||||
lastMsgTime = now;
|
||||
|
||||
addChatMsg(tags['display-name'] || tags.username, message, tags.color);
|
||||
|
||||
if (message.startsWith('!') && ws.readyState === WebSocket.OPEN) {
|
||||
const name = message.split(' ')[0].slice(1).toLowerCase();
|
||||
if (name) ws.send(JSON.stringify({ type: 'command', name, user: tags['display-name'] || tags.username }));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Chat display ──────────────────────────────────────────────────────
|
||||
const chatEl = document.getElementById('chat');
|
||||
|
||||
function addChatMsg(username, message, color) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-msg';
|
||||
el.innerHTML = `<span class="uname" style="color:${color || '#9b59b6'}">${esc(username)}</span>: ${esc(message)}`;
|
||||
chatEl.appendChild(el);
|
||||
while (chatEl.children.length > MAX_MSG) chatEl.firstChild.remove();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── Alert queue ───────────────────────────────────────────────────────
|
||||
function enqueueAlert(msg) {
|
||||
alertQueue.push(msg);
|
||||
if (!alertBusy) nextAlert();
|
||||
}
|
||||
|
||||
function nextAlert() {
|
||||
if (!alertQueue.length) { alertBusy = false; return; }
|
||||
alertBusy = true;
|
||||
playAlert(alertQueue.shift());
|
||||
}
|
||||
|
||||
function playAlert(msg) {
|
||||
const alertEl = document.getElementById('alert');
|
||||
const videoWrap = document.getElementById('alert-video-wrap');
|
||||
const videoEl = document.getElementById('alert-video');
|
||||
|
||||
if (msg.alert_text) {
|
||||
alertEl.textContent = msg.alert_text;
|
||||
alertEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (msg.action_type === 'sound' && msg.media_url) {
|
||||
new Audio(msg.media_url).play().catch(() => {});
|
||||
}
|
||||
|
||||
if (msg.action_type === 'video' && msg.media_url) {
|
||||
videoEl.src = msg.media_url;
|
||||
videoWrap.style.display = 'block';
|
||||
videoEl.play().catch(() => {});
|
||||
videoEl.onended = () => {
|
||||
videoWrap.style.display = 'none';
|
||||
videoEl.src = '';
|
||||
alertEl.style.display = 'none';
|
||||
alertEl.textContent = '';
|
||||
nextAlert();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
alertEl.style.display = 'none';
|
||||
alertEl.textContent = '';
|
||||
nextAlert();
|
||||
}, ALERT_DURATION);
|
||||
}
|
||||
</script>
|
||||
{% if custom_js %}<script>{{ custom_js | safe }}</script>{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
54
templates/overlay_preview.html
Normal file
54
templates/overlay_preview.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="/static/overlay.css">
|
||||
{% if custom_css %}<style>{{ custom_css | safe }}</style>{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--chat-left: {{ layout.chat_left }}%;
|
||||
--chat-top: {{ layout.chat_top }}%;
|
||||
--chat-width: {{ layout.chat_width }}%;
|
||||
--chat-height: {{ layout.chat_height }}%;
|
||||
--chat-font-size: {{ layout.chat_font_size }}px;
|
||||
--chat-bg-opacity: {{ layout.chat_bg_opacity }};
|
||||
--chat-text-color: {{ layout.chat_text_color }};
|
||||
--alert-left: {{ layout.alert_left }}%;
|
||||
--alert-top: {{ layout.alert_top }}%;
|
||||
--alert-font-size: {{ layout.alert_font_size }}px;
|
||||
--alert-bg-opacity: {{ layout.alert_bg_opacity }};
|
||||
--alert-text-color: {{ layout.alert_text_color }};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="alert" style="display:none"></div>
|
||||
<div id="alert-video-wrap" style="display:none"><video id="alert-video" playsinline></video></div>
|
||||
<div id="chat">
|
||||
<div class="chat-msg"><span class="uname" style="color:#9b59b6">StreamFriend</span>: Let's goooo! 🎉</div>
|
||||
<div class="chat-msg"><span class="uname" style="color:#e74c3c">Viewer42</span>: PogChamp PogChamp</div>
|
||||
<div class="chat-msg"><span class="uname" style="color:#2ecc71">ChatUser</span>: great stream today!</div>
|
||||
<div class="chat-msg"><span class="uname" style="color:#f39c12">AnotherFan</span>: !airhorn</div>
|
||||
<div class="chat-msg"><span class="uname" style="color:#3498db">NewViewer</span>: first time here!</div>
|
||||
</div>
|
||||
<script>
|
||||
const ALERT_DURATION = {{ alert_duration_ms }};
|
||||
let alertTimer = null;
|
||||
|
||||
function showMockAlert(text) {
|
||||
const el = document.getElementById('alert');
|
||||
el.style.display = 'none';
|
||||
void el.offsetWidth;
|
||||
el.textContent = text || 'StreamFan just subscribed! 🎉';
|
||||
el.style.display = 'block';
|
||||
if (alertTimer) clearTimeout(alertTimer);
|
||||
alertTimer = setTimeout(() => { el.style.display = 'none'; }, ALERT_DURATION);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data?.type === 'preview-alert') showMockAlert(e.data.text);
|
||||
});
|
||||
</script>
|
||||
{% if custom_js %}<script>{{ custom_js | safe }}</script>{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
42
templates/partials/action_row.html
Normal file
42
templates/partials/action_row.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<div class="action-row" id="action-{{ action.id }}">
|
||||
<div class="action-info">
|
||||
<code>{{ action.event_type }}{% if action.event_detail %}:{{ action.event_detail }}{% endif %}</code>
|
||||
→
|
||||
<strong>{{ action.action_type }}</strong>
|
||||
{% if action.media_file %}
|
||||
<span class="muted">{{ action.media_file.original_name }}</span>
|
||||
{% endif %}
|
||||
{% if action.alert_text %}
|
||||
<span class="muted">"{{ action.alert_text }}"</span>
|
||||
{% endif %}
|
||||
{% if action.cooldown_seconds %}
|
||||
<small class="muted">{{ action.cooldown_seconds }}s cooldown</small>
|
||||
{% endif %}
|
||||
{% if just_tested %}
|
||||
<span class="tested-badge">✓ fired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="action-controls">
|
||||
<form hx-post="/actions/{{ action.id }}/test"
|
||||
hx-target="#action-{{ action.id }}"
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline">
|
||||
<button type="submit" class="outline small-btn">Test</button>
|
||||
</form>
|
||||
<form hx-post="/actions/{{ action.id }}/toggle"
|
||||
hx-target="#action-{{ action.id }}"
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline">
|
||||
<button type="submit" class="outline small-btn {% if not action.enabled %}secondary{% endif %}">
|
||||
{{ 'on' if action.enabled else 'off' }}
|
||||
</button>
|
||||
</form>
|
||||
<button hx-delete="/actions/{{ action.id }}"
|
||||
hx-target="#action-{{ action.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this action?"
|
||||
class="outline secondary small-btn">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
18
templates/partials/media_item.html
Normal file
18
templates/partials/media_item.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<div class="media-item" id="media-{{ file.id }}">
|
||||
<div class="media-info">
|
||||
<span class="media-type-badge {{ file.file_type }}">{{ file.file_type }}</span>
|
||||
<strong>{{ file.original_name }}</strong>
|
||||
</div>
|
||||
{% if file.file_type == "audio" %}
|
||||
<audio controls src="/static/media/{{ file.filename }}" preload="none"></audio>
|
||||
{% else %}
|
||||
<video controls src="/static/media/{{ file.filename }}" preload="none" style="max-width:200px;max-height:120px"></video>
|
||||
{% endif %}
|
||||
<button hx-delete="/media/{{ file.id }}"
|
||||
hx-target="#media-{{ file.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete {{ file.original_name }}?"
|
||||
class="outline secondary small-btn">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
65
templates/settings.html
Normal file
65
templates/settings.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Settings — BashyOverlay{% endblock %}
|
||||
{% block content %}
|
||||
<hgroup>
|
||||
<h1>Settings</h1>
|
||||
</hgroup>
|
||||
|
||||
{% if saved %}
|
||||
<div class="notice-success">Settings saved.</div>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>OBS browser source URL</h2>
|
||||
<p>Add this as a browser source in OBS:</p>
|
||||
<input type="text" value="{{ overlay_url }}" readonly onclick="this.select()" class="url-input">
|
||||
<small>Refresh the browser source in OBS after changing settings.</small>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<form method="post" action="/settings">
|
||||
<h2>Channel</h2>
|
||||
<label>
|
||||
Twitch channel name
|
||||
<input type="text" name="twitch_channel" value="{{ settings.twitch_channel }}" required placeholder="your_channel">
|
||||
</label>
|
||||
|
||||
<h2>Gap audio thresholds</h2>
|
||||
<p><small>A tone plays when a new message arrives after a silence. These control how the volume and pitch scale with the gap length.</small></p>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Silence threshold (seconds)
|
||||
<input type="number" name="gap_silence_threshold" value="{{ settings.gap_silence_threshold }}" min="0">
|
||||
<small>No sound if gap is shorter than this.</small>
|
||||
</label>
|
||||
<label>
|
||||
Full volume at (seconds)
|
||||
<input type="number" name="gap_scale_end" value="{{ settings.gap_scale_end }}" min="1">
|
||||
<small>Volume reaches 100% at this gap length.</small>
|
||||
</label>
|
||||
<label>
|
||||
Max pitch at (seconds)
|
||||
<input type="number" name="gap_pitch_scale_end" value="{{ settings.gap_pitch_scale_end }}" min="1">
|
||||
<small>Pitch stops scaling up after this gap length.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2>Custom CSS</h2>
|
||||
<p><small>Injected into the overlay after the default styles. Overrides anything. Use <code>#chat</code>, <code>.chat-msg</code>, <code>#alert</code>.</small></p>
|
||||
<textarea name="custom_css" class="code-textarea" rows="10" placeholder="/* example */ #alert { border: 2px solid #9146ff; box-shadow: 0 0 20px #9146ff; } .chat-msg { border-left: 3px solid; }">{{ settings.custom_css }}</textarea>
|
||||
|
||||
<h2>Custom JS</h2>
|
||||
<p><small>Injected after the overlay scripts. Hook into events with <code>document.addEventListener('bashyoverlay:sub', e => ...)</code>. Available events: <code>sub</code>, <code>gift_sub</code>, <code>bits</code>, <code>channel_points</code>, <code>command</code>, <code>follow</code>, <code>raid</code>.</small></p>
|
||||
<textarea name="custom_js" class="code-textarea" rows="10" placeholder="// example document.addEventListener('bashyoverlay:raid', e => { console.log('Raid from', e.detail.alert_text); });">{{ settings.custom_js }}</textarea>
|
||||
|
||||
<button type="submit">Save settings</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>EventSub</h2>
|
||||
<form method="post" action="/settings/reconnect">
|
||||
<button type="submit" class="outline">Reconnect EventSub</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue