commit 6ad661c22070134d8030bff69dbab97b1198749e Author: Bashy Date: Wed Apr 22 23:48:35 2026 +0300 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..053bcfe --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d39b694 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.db +*.pyc +__pycache__/ +.env +.venv/ +venv/ +static/media/* +!static/media/.gitkeep diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..505e38f --- /dev/null +++ b/SPEC.md @@ -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? diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..3c8bda0 --- /dev/null +++ b/app/auth.py @@ -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 diff --git a/app/cooldowns.py b/app/cooldowns.py new file mode 100644 index 0000000..87ac74e --- /dev/null +++ b/app/cooldowns.py @@ -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) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..d562d89 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/dispatch.py b/app/dispatch.py new file mode 100644 index 0000000..f2b52f0 --- /dev/null +++ b/app/dispatch.py @@ -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 diff --git a/app/eventsub.py b/app/eventsub.py new file mode 100644 index 0000000..97d1d7e --- /dev/null +++ b/app/eventsub.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..5ac968a --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..12c2a8c --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/actions.py b/app/routers/actions.py new file mode 100644 index 0000000..3e22342 --- /dev/null +++ b/app/routers/actions.py @@ -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, + }) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..1210380 --- /dev/null +++ b/app/routers/auth.py @@ -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") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..23477a8 --- /dev/null +++ b/app/routers/dashboard.py @@ -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, + }) diff --git a/app/routers/layout.py b/app/routers/layout.py new file mode 100644 index 0000000..061e099 --- /dev/null +++ b/app/routers/layout.py @@ -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} diff --git a/app/routers/media.py b/app/routers/media.py new file mode 100644 index 0000000..5595479 --- /dev/null +++ b/app/routers/media.py @@ -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("") diff --git a/app/routers/overlay.py b/app/routers/overlay.py new file mode 100644 index 0000000..4e79c0b --- /dev/null +++ b/app/routers/overlay.py @@ -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) diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 0000000..25aec27 --- /dev/null +++ b/app/routers/settings.py @@ -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) diff --git a/app/templates.py b/app/templates.py new file mode 100644 index 0000000..6176130 --- /dev/null +++ b/app/templates.py @@ -0,0 +1,3 @@ +from fastapi.templating import Jinja2Templates + +templates = Jinja2Templates(directory="templates") diff --git a/app/ws.py b/app/ws.py new file mode 100644 index 0000000..4344045 --- /dev/null +++ b/app/ws.py @@ -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() diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..d0b40d7 --- /dev/null +++ b/deploy.sh @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..834259f --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/media/.gitkeep b/static/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/overlay.css b/static/overlay.css new file mode 100644 index 0000000..aa874a3 --- /dev/null +++ b/static/overlay.css @@ -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); } +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..161991e --- /dev/null +++ b/static/style.css @@ -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); } diff --git a/templates/actions.html b/templates/actions.html new file mode 100644 index 0000000..1da297e --- /dev/null +++ b/templates/actions.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Actions — BashyOverlay{% endblock %} +{% block content %} +
+

Actions

+

Configure what happens when each event fires.

+
+ +
+

Add action

+
+
+ + + + + + +
+ +
+
+ +
+

Configured actions

+
+ {% for action in actions %} + {% set _ = namespace(media_files=media_files) %} + {% include "partials/action_row.html" %} + {% endfor %} + {% if not actions %} +

No actions configured yet.

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ed49922 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}BashyOverlay{% endblock %} + + + + + +
+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..8694d2f --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Dashboard — BashyOverlay{% endblock %} +{% block content %} +
+

Dashboard

+

Welcome, {{ user.twitch_display_name }}

+
+ +
+ + EventSub: {% if eventsub_connected %}connected{% else %}disconnected{% endif %} + {% if not eventsub_connected %} +
+ +
+ {% endif %} +
+ +
+

Recent activity

+ {% if logs %} +
+ + + + + + + + + + + + {% for log in logs %} + + + + + + + + {% endfor %} + +
TimeEventUserDetailAction taken
{{ log.triggered_at.strftime('%H:%M:%S') }}{{ log.event_type }}{{ log.username or '—' }}{{ log.detail or '—' }}{{ log.action_taken or 'none configured' | safe }}
+
+ {% else %} +

No activity yet. Events will appear here as they come in.

+ {% endif %} +
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..f8cc122 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,200 @@ +{% extends "base.html" %} +{% block title %}Layout Editor — BashyOverlay{% endblock %} +{% block content %} +
+

Layout Editor

+

Drag handles to reposition. Resize chat from the bottom-right corner. Changes to styles preview live.

+
+ +{% if saved %} +
Styles saved. Refresh your OBS browser source to apply.
+{% endif %} + +
+ + +
+
Chat
+
+
+ +
+
Alert
+
+
+ + + +
+
+
+ Chat +
+ + + + +
+
+ +
+ Alert +
+ + + + +
+
+
+ +
+ + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..91b9898 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,19 @@ + + + + + + Login — BashyOverlay + + + + +
+ +
+ + diff --git a/templates/media.html b/templates/media.html new file mode 100644 index 0000000..131237d --- /dev/null +++ b/templates/media.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Media — BashyOverlay{% endblock %} +{% block content %} +
+

Media

+

Upload sounds and videos for your actions.

+
+ +
+

Upload

+
+ + +
+
+ +
+

Library

+
+ {% for file in files %} + {% include "partials/media_item.html" %} + {% endfor %} + {% if not files %} +

No files uploaded yet.

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/overlay.html b/templates/overlay.html new file mode 100644 index 0000000..f985f93 --- /dev/null +++ b/templates/overlay.html @@ -0,0 +1,162 @@ + + + + + + Overlay + + {% if custom_css %}{% endif %} + + + + + +
+ + + + {% if custom_js %}{% endif %} + + diff --git a/templates/overlay_preview.html b/templates/overlay_preview.html new file mode 100644 index 0000000..20354bc --- /dev/null +++ b/templates/overlay_preview.html @@ -0,0 +1,54 @@ + + + + + + {% if custom_css %}{% endif %} + + + + + +
+
StreamFriend: Let's goooo! 🎉
+
Viewer42: PogChamp PogChamp
+
ChatUser: great stream today!
+
AnotherFan: !airhorn
+
NewViewer: first time here!
+
+ + {% if custom_js %}{% endif %} + + diff --git a/templates/partials/action_row.html b/templates/partials/action_row.html new file mode 100644 index 0000000..2236296 --- /dev/null +++ b/templates/partials/action_row.html @@ -0,0 +1,42 @@ +
+
+ {{ action.event_type }}{% if action.event_detail %}:{{ action.event_detail }}{% endif %} + → + {{ action.action_type }} + {% if action.media_file %} + {{ action.media_file.original_name }} + {% endif %} + {% if action.alert_text %} + "{{ action.alert_text }}" + {% endif %} + {% if action.cooldown_seconds %} + {{ action.cooldown_seconds }}s cooldown + {% endif %} + {% if just_tested %} + ✓ fired + {% endif %} +
+
+
+ +
+
+ +
+ +
+
diff --git a/templates/partials/media_item.html b/templates/partials/media_item.html new file mode 100644 index 0000000..74f78c6 --- /dev/null +++ b/templates/partials/media_item.html @@ -0,0 +1,18 @@ +
+
+ {{ file.file_type }} + {{ file.original_name }} +
+ {% if file.file_type == "audio" %} + + {% else %} + + {% endif %} + +
diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..ff4d594 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Settings — BashyOverlay{% endblock %} +{% block content %} +
+

Settings

+
+ +{% if saved %} +
Settings saved.
+{% endif %} + +
+

OBS browser source URL

+

Add this as a browser source in OBS:

+ + Refresh the browser source in OBS after changing settings. +
+ +
+
+

Channel

+ + +

Gap audio thresholds

+

A tone plays when a new message arrives after a silence. These control how the volume and pitch scale with the gap length.

+
+ + + +
+ +

Custom CSS

+

Injected into the overlay after the default styles. Overrides anything. Use #chat, .chat-msg, #alert.

+ + +

Custom JS

+

Injected after the overlay scripts. Hook into events with document.addEventListener('bashyoverlay:sub', e => ...). Available events: sub, gift_sub, bits, channel_points, command, follow, raid.

+ + + +
+
+ +
+

EventSub

+
+ +
+
+{% endblock %}