Compare commits

...

56 Commits

Author SHA1 Message Date
3f2f0fcb4e eod: status 2025-11-17 19:08:36 +01:00
036e9da25e asyncio improvement 2025-11-17 18:43:32 +01:00
bdd8674645 New eventsub 2025-11-17 18:34:15 +01:00
c3d77974e3 class NullAdapter 2025-11-17 18:27:00 +01:00
2f0e817e12 added kwargs 2025-11-17 18:24:15 +01:00
fdda47da7b remove AIOhttp 2025-11-17 18:22:12 +01:00
9b7c9e8671 Rewrite adapter port 2025-11-17 18:18:37 +01:00
ab832e928f twitchio==3.1.0 2025-11-17 18:11:06 +01:00
691c704ecc Edite requirements 2025-11-17 18:08:43 +01:00
5806cb08e1 rework twitchIOMessage statement 2025-11-17 17:59:47 +01:00
e7801287c2 from twitchio import Message as TwitchIOMessage 2025-11-17 17:45:02 +01:00
d808c17ab6 twitchio.client import Message 2025-11-17 17:43:38 +01:00
3e604a9816 event_messenger 2025-11-17 17:41:32 +01:00
31f7e00538 twitchio.ext.commands correction 2025-11-17 17:39:38 +01:00
4d360abd21 chat listener updated 2025-11-17 17:37:19 +01:00
35410dec0a cahat_listener.py 2025-11-17 17:29:03 +01:00
8e6bd96d44 adjust listener 2025-11-17 17:27:28 +01:00
4936966c32 adjustment 2025-11-17 17:22:34 +01:00
63780b19b1 new chat_listener.py 2025-11-17 16:32:32 +01:00
ae2cfc60a0 Troubleshooting message, self statement 2025-11-17 16:29:49 +01:00
6520ea0b63 Troubleshooting db_user_id statement 2025-11-17 16:25:00 +01:00
7f22cec452 Swapping mapping for websocket return traffic 2025-11-17 15:55:18 +01:00
6b2d296774 Adding twitchio port 8123 2025-11-17 15:47:23 +01:00
d1c4c931ef Changing websocket port 2025-11-17 15:45:16 +01:00
d8086afab2 Added websockets in imports in main.py 2025-11-17 15:36:38 +01:00
b60c642fd6 Attempting to add websocket support 2025-11-17 15:34:01 +01:00
2fe07abecf Not excluding messages from "self" in the return statement 2025-11-17 15:29:37 +01:00
30b9df0ebb Editing return message statement in chat_listener.py 2025-11-17 15:25:52 +01:00
c061f2ff78 Forgot a comma in the code 2025-11-17 15:22:33 +01:00
034c8fe604 Correction to init for access token 2025-11-17 15:20:10 +01:00
5dc73bd06a Updated chat_listener and listener_manager 2025-11-17 15:08:29 +01:00
4013d3d23d Addidng chat_listener function for Twitch, adding custom overlay template for testing. 2025-11-17 14:46:50 +01:00
98cda57d90 Phase 2 update. TASKS and Development_plan edited 2025-11-17 14:36:13 +01:00
aa9688d811 Starting adding help-file for user made CSS templates 2025-11-17 14:31:54 +01:00
8bc24fc80a Corrected Response import in main.py 2025-11-17 14:26:44 +01:00
1535d90842 Still updating database schemas 2025-11-17 14:23:54 +01:00
0fa46d4cca Correcting schemas in database 2025-11-17 14:17:30 +01:00
589ac73b25 Starting implementation of user made custom CSS templates 2025-11-17 14:14:45 +01:00
a120e30e03 Set dark mode to default regardless of user browser settings 2025-11-17 13:24:14 +01:00
65adbf1aaa added light mode to site 2025-11-17 13:08:51 +01:00
5ff361b97c Added back the css templates and added logout button 2025-11-17 13:03:25 +01:00
883439e66f Changes to login 2025-11-17 12:59:25 +01:00
67c0d0124b Small corrections 2025-11-17 12:46:24 +01:00
9d95e1fd3c css restore 2025-11-17 12:20:57 +01:00
e0502f93d6 start rebuild main.css 2025-11-17 12:18:29 +01:00
519cc907af main.css removed 2025-11-17 12:15:11 +01:00
77f0a8a395 Corrected error in file path to templates 2025-11-17 12:13:59 +01:00
7f08ad86b0 Updated file paths for CSS 2025-11-17 12:10:42 +01:00
0e0135be55 Correcting requirements 2025-11-17 12:00:16 +01:00
6af58808ad Phase 2: User experience enhancements to templates handling, dashboard and overlays. 2025-11-17 11:55:53 +01:00
6010666dcf Fix: small type-o 2025-11-17 10:35:51 +01:00
43b8610aa6 Added: CSS template switch in Dashboard. 2025-11-17 03:06:25 +01:00
d72e27ad2e Added: Temporary overlay text and 2 css layouts. 2025-11-17 03:04:26 +01:00
0c00ff7ee0 gitea logo png
For embeds
2025-11-17 02:53:12 +01:00
bce05652ed refactor: Organize templates into templates directory 2025-11-17 02:38:25 +01:00
830a16d693 Small update to main.py 2025-11-17 02:36:27 +01:00
35 changed files with 1328 additions and 86 deletions

View File

@@ -25,19 +25,11 @@ My core instructions are:
The objective is to build a multi-platform chat overlay SaaS (Software as a Service) for streamers. The service will aggregate chat from Twitch and YouTube into a single, customizable browser source for use in streaming software like OBS.
### Current Status
The project is in **Phase 1: User Authentication & Database**. Most of this phase is complete.
* A solid FastAPI application skeleton is in place.
* The database schema (`User`, `Setting` models) is defined using SQLAlchemy and a SQLite database.
* A secure Twitch OAuth2 authentication flow is fully functional. It correctly:
1. Redirects users to Twitch.
2. Handles the callback.
3. Exchanges the authorization code for tokens.
4. Fetches user details from the Twitch API.
5. Encrypts the tokens using the `cryptography` library.
6. Saves or updates the user's record in the database.
**Phases 1, 2, and 3 are complete.** The application is now a fully functional chat overlay service for Twitch.
* **Phase 1 (Authentication):** A secure Twitch OAuth2 flow is implemented, with user data and encrypted tokens stored in a SQLite database.
* **Phase 2 (Dashboard & Configuration):** A dynamic user dashboard is available after login. It includes a theme switcher (light/dark), a theme selector for the overlay, and a full CRUD system for users to create and manage their own private CSS themes.
* **Phase 3 (Real-time Chat):** A decoupled background listener manager successfully starts `twitchio` listeners for each user. A WebSocket manager broadcasts incoming chat messages to the correct user's overlay in real-time.
* A basic HTML login page is served at the root URL (`/`).
* Configuration and secrets are managed securely via a `config.py` file that reads from a `.env` file.
### Core Architecture
The project is built on the "hybrid architecture" detailed in the `RESEARCH_REPORT.md`:
* **Authentication:** Always use the official, secure OAuth2 flows for each platform.

View File

@@ -17,6 +17,8 @@ The goal is to create a service where streamers can log in using their platform
## 3. Implementation Roadmap
### Phase 1: User Authentication & Database (FastAPI)
**Status: ✔️ Complete**
1. **Project Skeleton:** Establish the core FastAPI application structure, dependencies, and version control.
2. **Database Schema:** Define the data models for users and settings using SQLAlchemy.
3. **Twitch OAuth2:** Implement the server-side OAuth2 flow within FastAPI to authenticate users and securely store encrypted tokens in the database.
@@ -24,16 +26,25 @@ The goal is to create a service where streamers can log in using their platform
5. **Basic Frontend:** Develop a simple login page.
### Phase 2: User Dashboard & Configuration
**Status: ✔️ Complete**
1. **Dashboard UI:** Create a dashboard page accessible only to authenticated users.
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings (e.g., custom CSS).
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user on their dashboard.
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings.
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user.
4. **Theming System:** Implement a site-wide light/dark theme switcher.
5. **Custom CSS Themes:** Develop a full CRUD (Create, Read, Update, Delete) system allowing users to create, manage, and preview their own private CSS overlay themes.
6. **Help & Documentation:** Add a help page to guide users in creating their custom CSS.
### Phase 3: Dynamic Listeners & Basic Overlay
**Status: ✔️ Complete**
1. **Dynamic Listener Manager:** Design and build a background service that starts and stops chat listener processes (`twitchio`, `pytchat`) based on user activity.
2. **Real-time Message Broadcasting:** Implement a WebSocket system within FastAPI to push chat messages to the correct user's overlay in real-time.
3. **Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and renders incoming chat messages.
### Phase 4: Integration & Refinement
**Status: ⏳ Not Started**
1. **YouTube Integration:** Implement the full YouTube OAuth2 flow and integrate the `pytchat` listener into the dynamic listener manager.
2. **Advanced Overlay Customization:** Add more features for users to customize their overlay's appearance and behavior.
3. **Twitch Chat Writeback:** Re-introduce the `chat:write` scope during authentication to allow the service (and potentially moderators, as per Issue #2) to send messages to the user's Twitch chat.

View File

@@ -2,7 +2,7 @@
MultiChatOverlay is a web-based, multi-platform chat overlay service designed for streamers. The goal is to create a "SaaS" (Software as a Service) project where users can log in with their platform accounts (Twitch, YouTube, etc.) and get a single, unified, and customizable chat overlay for their stream.
This project is currently in **Phase 1: Initial Development**.
This project is currently in **Phase 2: User Dashboard & Configuration**.
## 🚀 Project Goal
@@ -15,7 +15,7 @@ This project is currently in **Phase 1: Initial Development**.
User privacy and security are paramount. All sensitive user credentials, such as OAuth access and refresh tokens from external platforms, are **always encrypted** before being stored in the database. They are never stored in plain text, ensuring a high standard of security for user data.
## <EFBFBD> Technology Stack
## 🖥️ Technology Stack
* **Backend:** Python 3.9+ (FastAPI)
* **Database:** SQLite (initially, for simplicity) with SQLAlchemy

View File

@@ -10,7 +10,7 @@ This file tracks all active development tasks. It is based on the official `DEVE
4. When your Pull Request is *merged*, move it to "Done."
If you want to use emojis for visibility, here's some I have used:
✔️ - Done | 🧑‍🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md)
✔️ - Done | 🧑‍🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) )
---
@@ -20,8 +20,6 @@ If you want to use emojis for visibility, here's some I have used:
### To Do
### In Progress
### Done
* `[✔️]` **1.0: Project Skeleton** - @ramforth
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
@@ -35,33 +33,37 @@ If you want to use emojis for visibility, here's some I have used:
## ⏳ Phase 2: User Dashboard & Configuration
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings.
* *(All tasks for this phase are on hold until Phase 1 is complete)*
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings. Now that Phase 1 is done, these tasks are ready to be worked on.
### To Do
* `[ ]` **2.0: CSS Refactor & Styling:** Improve the general look and feel of the application pages.
* `[ ]` **2.1: Dashboard UI:** Create `dashboard.html` (only for logged-in users).
* `[ ]` **2.2: Config API:** Create API endpoints (`GET`, `POST`) for `/api/settings` to save/load user preferences (e.g., custom CSS).
* `[ ]` **2.3: Overlay URL:** Generate and display the unique overlay URL for the user (e.g., `/overlay/{user_id}`).
* `[ ]` **2.4: Create Logo and Favicon:** The project should have a logo and a favicon.
### Done
* `[✔️]` **2.0: CSS Refactor & Styling:** Improved the general look and feel of the application pages, including a light/dark theme switcher.
* `[✔️]` **2.1: Dashboard UI:** Created `dashboard.html` for logged-in users to manage settings.
* `[✔️]` **2.2: Config API:** Created API endpoints for `/api/settings` to save user preferences.
* `[✔️]` **2.3: Overlay URL:** Generated and displayed the unique overlay URL for the user on the dashboard.
* `[✔️]` **2.5: Custom CSS Themes:** Implemented a system for users to create, preview, and delete their own private CSS overlay themes.
* `[✔️]` **2.6: CSS Help Page:** Created a guide for users on how to write custom CSS for their overlays.
---
## 💬 Phase 3: Dynamic Listeners & Basic Overlay
## 💬 Phase 3: Real-time Chat & Overlay
* **Goal:** The core magic. Start chat listeners for users and show messages in the overlay.
* *(All tasks for this phase are on hold until Phase 2 is complete)*
### To Do
* `[ ]` **3.1: Dynamic Listener Manager (The Big One):** Design a system (e.g., background service) to start/stop listener processes for users.
* `[ ]` **3.2: User-Specific Broadcasting:** Update the WebSocket system to use "rooms" (e.g., `/ws/{user_id}`) so users only get their *own* chat.
* `[ ]` **3.3: Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and displays messages.
---
### Done
* `[✔️]` **3.1: Dynamic Listener Manager:** Designed and implemented a system to start/stop listener processes for users on application startup/shutdown.
* `[✔️]` **3.2: User-Specific Broadcasting:** Implemented a WebSocket manager and endpoint (`/ws/{user_id}`) to broadcast messages to the correct user's overlay.
* `[✔️]` **3.3: Basic Overlay UI:** Created dynamic overlay templates that connect to the WebSocket and display incoming chat messages.
## 💡 Backlog & Future Features
* *(Tasks from Phase 4, Gitea Issues, etc., will be added here as we go)*
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
* `[ ]` "Single Message Focus" feature (Issue #1).
* `[ ]` Moderator panels (Issue #2).
* `[ ]` Custom CSS storage & injection (Issue #6).
* `[ ]` Moderator panels (Issue #2).

View File

@@ -99,6 +99,9 @@ async def auth_twitch_callback(code: str, state: str, request: Request, db: Sess
encrypted_tokens=encrypted_tokens
)
db.add(user)
# Also create a default settings object for the new user
new_settings = models.Setting(owner=user)
db.add(new_settings)
db.commit()

60
chat_listener.py Normal file
View File

@@ -0,0 +1,60 @@
from twitchio.ext import commands
class NullAdapter:
"""A dummy web adapter that does nothing to prevent twitchio from starting a webserver."""
async def start(self):
pass
async def stop(self):
pass
class TwitchBot(commands.Bot):
def __init__(self, access_token: str, client_id: str, client_secret: str, bot_id: str, channel_name: str, websocket_manager, db_user_id: int):
self.websocket_manager = websocket_manager
# Store our application's database user ID to avoid conflict with twitchio's internal 'owner_id'
self.db_user_id = db_user_id
# Force the bot to use our dummy adapter that does nothing.
# This is the definitive fix to prevent any port conflicts.
adapter = NullAdapter()
super().__init__(
token=access_token,
# Mandate: Explicitly disable the internal web server.
web_server_adapter=adapter,
eventsub_url=None,
prefix='!', # A prefix is required but won't be used for reading chat
initial_channels=[channel_name],
# These are required by twitchio
client_id=client_id,
client_secret=client_secret,
# The 'bot_id' is the Twitch ID of the user account the bot is running as
id=bot_id
)
self.channel_name = channel_name
async def event_ready(self):
"""Called once when the bot goes online."""
print(f"Listener ready for #{self.channel_name}")
async def event_message(self, message): # Mandate: Type hint removed to prevent import errors.
"""Runs every time a message is sent in chat."""
# This is CRITICAL when overriding a listener in commands.Bot
# to ensure the bot can still process commands if any are added.
await super().event_message(message)
# Ignore messages sent by the bot itself to prevent loops.
if message.echo:
return
# Prepare the message data to be sent to the frontend
chat_data = {
"author": message.author.name,
"text": message.content,
"platform": "twitch"
}
# Broadcast the message to the specific user's overlay
# We need the user's ID to know which WebSocket connection to send to.
user_id = self.db_user_id
await self.websocket_manager.broadcast_to_user(user_id, chat_data)

View File

@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - MultiChatOverlay</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Dashboard</h1>
<p>Welcome, <strong>{{ user.username }}</strong>! You are successfully logged in.</p>
<div class="overlay-url-container">
<p>Your unique overlay URL:</p>
<code>{{ overlay_url }}</code>
</div>
<p><a href="/logout">Logout</a></p>
</div>
</body>
</html>

51
listener_manager.py Normal file
View File

@@ -0,0 +1,51 @@
import asyncio
from typing import Dict
from chat_listener import TwitchBot
import security # To decrypt tokens
from config import settings # To get client_id and client_secret
class ListenerManager:
def __init__(self):
# This dictionary will hold our running listener tasks.
# The key will be the user_id and the value will be the asyncio.Task.
self.active_listeners: Dict[int, asyncio.Task] = {}
print("ListenerManager initialized.")
async def start_listener_for_user(self, user, websocket_manager):
"""
Starts a chat listener for a given user if one isn't already running.
"""
if user.id in self.active_listeners:
print(f"Listener for user {user.id} is already running.")
return
print(f"Starting listener for user {user.id} ({user.username})...")
tokens = security.decrypt_tokens(user.encrypted_tokens)
access_token = tokens['access_token']
bot = TwitchBot(
access_token=access_token,
channel_name=user.username,
client_id=settings.TWITCH_CLIENT_ID,
client_secret=settings.TWITCH_CLIENT_SECRET,
bot_id=user.platform_user_id,
websocket_manager=websocket_manager,
db_user_id=user.id
)
task = asyncio.create_task(bot.start())
self.active_listeners[user.id] = task
async def stop_listener_for_user(self, user_id: int):
"""Stops a chat listener for a given user."""
if user_id not in self.active_listeners:
print(f"No active listener found for user {user_id}.")
return
print(f"Stopping listener for user {user_id}...")
task = self.active_listeners.pop(user_id)
task.cancel()
try:
await task
except asyncio.CancelledError:
print(f"Listener for user {user_id} successfully stopped.")

0
main.css Normal file
View File

151
main.py
View File

@@ -1,15 +1,22 @@
import os
from fastapi import FastAPI, Request, Depends
from starlette.middleware.sessions import SessionMiddleware
from starlette.staticfiles import StaticFiles
import asyncio
from fastapi import FastAPI, Request, Depends, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from starlette.websockets import WebSocket
from contextlib import asynccontextmanager
from sqlalchemy.orm import Session
import models
from database import engine
from database import engine, SessionLocal
import auth # Import the new auth module
import schemas
from starlette.responses import Response
from config import settings # Import settings to get the secret key
from listener_manager import ListenerManager
from websocket_manager import WebSocketManager
# --- Absolute Path Configuration ---
# Get the absolute path of the directory where this file is located
@@ -17,14 +24,39 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
@asynccontextmanager
async def background_listener_startup(app: FastAPI):
"""A non-blocking task to start listeners after the app has started."""
print("Background task: Starting listeners for all users...")
db = SessionLocal()
users = db.query(models.User).all()
db.close()
for user in users:
# Use try/except to ensure one failing listener doesn't stop others
try:
await app.state.listener_manager.start_listener_for_user(user, app.state.websocket_manager)
except Exception as e:
print(f"ERROR: Failed to start listener for user {user.id} ({user.username}): {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# This code runs on startup
print("Application startup: Creating database tables...")
app.state.websocket_manager = WebSocketManager()
app.state.listener_manager = ListenerManager()
models.Base.metadata.create_all(bind=engine)
print("Application startup: Database tables created.")
# Decouple listener startup from the main application startup
asyncio.create_task(background_listener_startup(app))
yield
# Code below yield runs on shutdown, if needed
# This code runs on shutdown
print("Application shutdown: Stopping all listeners...")
manager = app.state.listener_manager
# Create a copy of keys to avoid runtime errors from changing dict size
for user_id in list(manager.active_listeners.keys()):
await manager.stop_listener_for_user(user_id)
app = FastAPI(lifespan=lifespan)
@@ -42,8 +74,8 @@ app.include_router(auth.router)
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
@app.get("/")
async def read_root():
return FileResponse(os.path.join(STATIC_DIR, "login.html"))
async def read_root(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
@app.get("/dashboard")
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
@@ -55,11 +87,112 @@ async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
user = db.query(models.User).filter(models.User.id == user_id).first()
overlay_url = f"{settings.APP_BASE_URL}/overlay/{user.id}"
# Ensure user has settings, create if they don't for some reason
if not user.settings:
user.settings = models.Setting()
db.commit()
return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "overlay_url": overlay_url})
return templates.TemplateResponse("dashboard.html", {
"request": request,
"user": user,
"overlay_url": overlay_url,
"current_theme": user.settings.overlay_theme,
"settings": settings,
"custom_themes": user.custom_themes
})
@app.get("/logout")
async def logout(request: Request):
# Clear the session cookie
request.session.clear()
return RedirectResponse(url="/")
return RedirectResponse(url="/")
@app.get("/help/css")
async def css_help(request: Request):
return templates.TemplateResponse("help_css.html", {"request": request})
@app.get("/overlay/{user_id}")
async def read_overlay(request: Request, user_id: int, theme_override: str = None, db: Session = Depends(auth.get_db)):
# This endpoint serves the overlay page.
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
theme_name = "dark-purple" # Default theme
if theme_override:
theme_name = theme_override
elif user.settings and user.settings.overlay_theme:
theme_name = user.settings.overlay_theme
# Check if it's a custom theme
if theme_name.startswith("custom-"):
theme_id = int(theme_name.split("-")[1])
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id, models.CustomTheme.owner_id == user.id).first()
if not theme:
raise HTTPException(status_code=404, detail="Custom theme not found")
# Use a generic overlay template that will link to the dynamic CSS
return templates.TemplateResponse("overlay-custom.html", {"request": request, "theme_id": theme.id})
return templates.TemplateResponse(f"overlay-{theme_name}.html", {"request": request})
@app.post("/api/settings")
async def update_settings(settings_data: schemas.SettingsUpdate, request: Request, db: Session = Depends(auth.get_db)):
user_id = request.session.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user.settings:
user.settings = models.Setting()
user.settings.overlay_theme = settings_data.overlay_theme
db.commit()
return {"message": "Settings updated successfully"}
@app.post("/api/themes", response_model=schemas.CustomTheme)
async def create_theme(theme_data: schemas.CustomThemeCreate, request: Request, db: Session = Depends(auth.get_db)):
user_id = request.session.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
new_theme = models.CustomTheme(**theme_data.dict(), owner_id=user_id)
db.add(new_theme)
db.commit()
db.refresh(new_theme)
return new_theme
@app.delete("/api/themes/{theme_id}")
async def delete_theme(theme_id: int, request: Request, db: Session = Depends(auth.get_db)):
user_id = request.session.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id, models.CustomTheme.owner_id == user_id).first()
if not theme:
raise HTTPException(status_code=404, detail="Theme not found")
db.delete(theme)
db.commit()
return {"message": "Theme deleted successfully"}
@app.get("/css/custom/{theme_id}")
async def get_custom_css(theme_id: int, db: Session = Depends(auth.get_db)):
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id).first()
if not theme:
raise HTTPException(status_code=404, detail="Custom theme not found")
return Response(content=theme.css_content, media_type="text/css")
@app.websocket("/ws/{user_id}")
async def websocket_endpoint(websocket: WebSocket, user_id: int):
manager = websocket.app.state.websocket_manager
await manager.connect(user_id, websocket)
try:
while True:
# Keep the connection alive
await websocket.receive_text()
except Exception:
manager.disconnect(user_id, websocket)
print(f"WebSocket for user {user_id} disconnected.")

View File

@@ -16,12 +16,24 @@ class User(Base):
encrypted_tokens = Column(Text, nullable=False)
settings = relationship("Setting", back_populates="owner", uselist=False)
custom_themes = relationship("CustomTheme", back_populates="owner")
class Setting(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True)
custom_css = Column(Text, nullable=True)
overlay_theme = Column(String, default="dark-purple")
user_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="settings")
owner = relationship("User", back_populates="settings")
class CustomTheme(Base):
__tablename__ = "custom_themes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
css_content = Column(Text, nullable=False)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="custom_themes")

View File

@@ -5,4 +5,7 @@ httpx
cryptography
python-dotenv
itsdangerous
jinja2
jinja2
pydantic
python-jose[cryptography]
twitchio==3.1.0

19
schemas.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
class SettingsUpdate(BaseModel):
overlay_theme: str
class CustomThemeBase(BaseModel):
name: str
css_content: str
class CustomThemeCreate(CustomThemeBase):
pass
class CustomTheme(CustomThemeBase):
id: int
owner_id: int
class Config:
# This allows the Pydantic model to be created from a SQLAlchemy ORM object
from_attributes = True

291
static/css/main.css Normal file
View File

@@ -0,0 +1,291 @@
/* --- Theme Variables and CSS Reset --- */
:root,
[data-theme="dark"] {
--background-color: #1a1a2e;
--surface-color: #16213e;
--text-color: #e0e0e0;
--text-muted-color: #a0a0a0;
--text-inverted-color: #ffffff;
--border-color: #4f4f7a;
--primary-color: #7f5af0;
--primary-hover-color: #6a48d7;
}
[data-theme="light"] {
--background-color: #f0f2f5;
--surface-color: #ffffff;
--text-color: #1c1e21;
--text-muted-color: #606770;
--text-inverted-color: #ffffff;
--border-color: #dddfe2;
--primary-color: #7f5af0;
--primary-hover-color: #6a48d7;
}
/* Box-sizing reset */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Reset margins and paddings on most elements */
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
figure,
blockquote,
dl,
dd {
margin: 0;
}
/* Basic body styling */
body {
min-height: 100vh;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
background-color: var(--background-color);
color: var(--text-color);
transition: background-color 0.2s, color 0.2s;
}
/* Make images responsive */
img,
picture {
max-width: 100%;
display: block;
}
/* General Container for login and dashboard */
.container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 1rem;
}
/* Login Page Styles */
.login-container {
width: 100%;
padding: 1rem;
}
.login-box {
width: 100%;
max-width: 400px;
margin: 0 auto;
margin-top: 5vh;
padding: 2.5rem;
background-color: var(--surface-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
text-align: center;
}
.login-box h1 {
color: var(--text-color);
margin-bottom: 0.5rem;
}
.login-box p {
margin-bottom: 2rem;
color: var(--text-muted-color);
}
.input-group {
margin-bottom: 1.5rem;
text-align: left;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--text-muted-color);
}
.input-group input {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-color);
font-size: 1rem;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 6px;
background-color: var(--primary-color);
color: var(--text-inverted-color);
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary:hover {
background-color: var(--primary-hover-color);
}
.login-footer {
margin-top: 1.5rem;
}
.login-footer a {
color: var(--primary-color);
text-decoration: none;
font-size: 0.9rem;
}
.login-footer a:hover {
text-decoration: underline;
}
/* Header Styles */
.logo h1 {
font-size: 1.5rem;
color: var(--text-color);
}
.logo {
text-decoration: none;
}
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
width: 100%;
margin-bottom: 2rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.logout-btn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--primary-color);
color: var(--text-inverted-color);
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background-color 0.2s ease;
}
.logout-btn:hover {
background-color: var(--primary-hover-color);
}
.theme-btn {
background: none;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 5px;
border-radius: 50%;
}
.theme-btn:hover {
background-color: var(--surface-color);
}
[data-theme="light"] .moon-icon { display: block; }
[data-theme="light"] .sun-icon { display: none; }
[data-theme="dark"] .moon-icon { display: none; }
[data-theme="dark"] .sun-icon { display: block; }
/* Dashboard Card Styles */
.card {
background-color: var(--surface-color);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h2 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.card p {
margin-top: 0;
color: var(--text-muted-color);
}
/* Custom Theme Form Styles */
.theme-form {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
background-color: var(--background-color);
color: var(--text-color);
border-radius: 6px;
font-family: inherit;
font-size: 1rem;
}
.custom-theme-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 0.5rem;
background-color: var(--background-color);
}
.delete-theme-btn {
background-color: #e43f5a;
color: white;
border: none;
padding: 0.3rem 0.8rem;
border-radius: 4px;
cursor: pointer;
}
/* Styles for help page code examples */
pre {
background-color: var(--background-color);
border: 1px solid var(--border-color);
padding: 1rem;
border-radius: 6px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: monospace;
}
}

View File

@@ -0,0 +1,12 @@
body {
background-color: transparent;
font-family: 'Inter', sans-serif;
font-size: 16px;
margin: 0;
overflow: hidden;
}
.chat-container {
padding: 10px;
height: 100vh;
}

View File

@@ -0,0 +1,28 @@
body {
background-color: transparent;
color: #f0f0f0;
font-family: 'Inter', sans-serif;
font-size: 16px;
margin: 0;
padding: 10px;
overflow: hidden;
text-shadow: 0 0 5px rgba(57, 255, 20, 0.5);
}
.chat-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-message {
padding: 6px 10px;
background-color: rgba(10, 30, 10, 0.6);
border-radius: 6px;
border-left: 3px solid #39FF14; /* Neon green accent */
}
.username {
font-weight: 800;
color: #39FF14; /* Neon green */
}

View File

@@ -0,0 +1,28 @@
body {
background-color: transparent;
color: #e0e0e0;
font-family: 'Inter', sans-serif;
font-size: 16px;
margin: 0;
padding: 10px;
overflow: hidden;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);
}
.chat-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-message {
padding: 6px 10px;
background-color: rgba(26, 26, 46, 0.7); /* Dark blue/purple transparent background */
border-radius: 6px;
border: 1px solid rgba(127, 90, 240, 0.3);
}
.username {
font-weight: 800;
color: #a970ff; /* Twitch-like purple */
}

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - MultiChatOverlay</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Dashboard</h1>
<p>Welcome! You are successfully logged in.</p>
<p><a href="/logout">Logout</a></p>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,26 @@
body {
background-color: transparent;
color: #1a1a1a; /* Dark text for readability on light backgrounds */
font-family: 'Inter', sans-serif;
font-size: 18px;
margin: 0;
padding: 10px;
font-weight: 600;
-webkit-text-stroke: 0.5px white; /* White outline for text */
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5);
}
.chat-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-message {
padding: 5px 10px;
background-color: rgba(240, 240, 240, 0.5); /* Semi-transparent light background */
border-radius: 5px;
border-left: 3px solid #00ff7f; /* Spring green accent */
}
.username {
font-weight: 800;
color: #008000; /* Dark green for usernames */
}

View File

@@ -0,0 +1,24 @@
body {
background-color: transparent;
color: #e6edf3; /* Light text for dark backgrounds */
font-family: 'Inter', sans-serif;
font-size: 16px;
margin: 0;
padding: 10px;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.9);
}
.chat-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-message {
padding: 4px 8px;
background-color: rgba(22, 27, 34, 0.6); /* Semi-transparent dark background */
border-radius: 4px;
border-left: 3px solid #9146FF; /* Twitch purple accent */
}
.username {
font-weight: 800;
color: #a970ff; /* Lighter purple for usernames */
}

55
templates/base.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MultiChat Overlay{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', path='css/main.css') }}">
</head>
<body>
<div class="container">
<header class="main-header">
<a href="/" class="logo"><h1>MultiChat Overlay</h1></a>
<div class="header-actions">
<button id="theme-toggle" class="theme-btn" title="Toggle theme">
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.64 5.64c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39 1.02.39 1.41 0s.39-1.02 0-1.41L5.64 5.64zm12.72 12.72c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41l-1.06-1.06zM5.64 18.36l-1.06-1.06c-.39-.39-.39-1.02 0-1.41s1.02-.39 1.41 0l1.06 1.06c.39.39.39 1.02 0 1.41s-1.02.39-1.41 0zm12.72-12.72l-1.06-1.06c-.39-.39-.39-1.02 0-1.41s1.02-.39 1.41 0l1.06 1.06c.39.39.39 1.02 0 1.41-.39.39-1.02.39-1.41 0z"></path></svg>
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M9.37 5.51A7.35 7.35 0 0 0 9 6c0 4.42 3.58 8 8 8 .36 0 .72-.02 1.08-.06A7.5 7.5 0 0 1 9.37 5.51zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10A9.96 9.96 0 0 0 12 2z"></path></svg>
</button>
{% if user %}
<a href="/logout" class="logout-btn">Logout</a>
{% endif %}
</div>
</header>
{% block content %}{% endblock %}
</div>
<script>
(function() {
const themeToggle = document.getElementById('theme-toggle');
const getTheme = () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) return storedTheme;
// For new visitors, default to dark mode regardless of their OS setting.
return 'dark';
};
const setTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
};
themeToggle.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
});
// Set initial theme on page load
setTheme(getTheme());
})();
</script>
</body>
</html>

240
templates/dashboard.html Normal file
View File

@@ -0,0 +1,240 @@
{% extends "base.html" %}
{% block title %}Dashboard - {{ super() }}{% endblock %}
{% block content %}
<div class="card">
<h2>Welcome, {{ user.username }}!</h2>
<p>This is your personal dashboard. Here you can manage your overlay settings and find your unique URL.</p>
</div>
<div class="card">
<h2>Your Overlay URL</h2>
<p>Copy this URL and add it as a "Browser Source" in your streaming software (e.g., OBS, Streamlabs).</p>
<div class="url-box">
<input type="text" id="overlayUrl" value="{{ overlay_url }}" readonly>
<button id="copyButton" class="btn">Copy</button>
</div>
</div>
<div class="card">
<h2>Overlay Theme</h2>
<p>Choose a theme for your chat overlay. Your selection will be saved automatically.</p>
<div class="theme-selector-container">
<div class="theme-options">
<label for="theme-select">Select a theme:</label>
<select id="theme-select" name="theme">
<option value="dark-purple" {% if current_theme == 'dark-purple' %}selected{% endif %}>Dark Purple</option>
<option value="bright-green" {% if current_theme == 'bright-green' %}selected{% endif %}>Bright Green</option>
<option value="minimal-light" {% if current_theme == 'minimal-light' %}selected{% endif %}>Minimal Light</option>
<option value="hacker-green" {% if current_theme == 'hacker-green' %}selected{% endif %}>Hacker Green</option>
{% if custom_themes %}
<optgroup label="Your Themes">
{% for theme in custom_themes %}
<option value="custom-{{ theme.id }}" {% if current_theme == 'custom-' ~ theme.id %}selected{% endif %}>{{ theme.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
</select>
</div>
<div class="theme-preview">
<h3>Preview</h3>
<iframe id="theme-preview-frame" src="" frameborder="0" scrolling="no"></iframe>
</div>
</div>
</div>
<div class="card">
<h2>
Custom Themes
<a href="/help/css" target="_blank" class="help-link" title="Open CSS guide in new window">(?)</a>
</h2>
<p>Create your own themes with CSS. These are private to your account.</p>
<div id="custom-themes-list">
{% for theme in custom_themes %}
<div class="custom-theme-item" id="theme-item-{{ theme.id }}">
<span>{{ theme.name }}</span>
<button class="delete-theme-btn" data-theme-id="{{ theme.id }}">Delete</button>
</div>
{% endfor %}
</div>
<form id="theme-form" class="theme-form">
<h3>Create New Theme</h3>
<div class="form-group">
<label for="theme-name">Theme Name</label>
<input type="text" id="theme-name" name="name" required>
</div>
<div class="form-group">
<label for="theme-css">CSS Content</label>
<textarea id="theme-css" name="css_content" rows="8" required placeholder="body { color: red; }"></textarea>
</div>
<button type="submit" class="btn-primary">Save Theme</button>
</form>
</div>
<style>
.help-link {
font-size: 0.9rem;
vertical-align: middle;
text-decoration: none;
color: var(--primary-color);
}
.url-box {
display: flex;
gap: 10px;
}
.url-box input {
flex-grow: 1;
padding: 10px;
border: 1px solid var(--border-color);
background-color: var(--background-color);
color: var(--text-color);
border-radius: 6px;
font-family: monospace;
}
.theme-selector-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
.theme-options select {
width: 100%;
padding: 10px;
border-radius: 6px;
border: 1px solid var(--border-color);
background-color: var(--surface-color);
color: var(--text-color);
font-size: 1rem;
}
.theme-preview {
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--background-color);
}
.theme-preview h3 {
margin: 0;
padding: 0.75rem 1rem;
font-size: 1rem;
border-bottom: 1px solid var(--border-color);
}
#theme-preview-frame {
width: 100%;
height: 150px;
border-radius: 0 0 8px 8px;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
// --- Copy URL Logic ---
const copyButton = document.getElementById('copyButton');
const overlayUrlInput = document.getElementById('overlayUrl');
copyButton.addEventListener('click', () => {
overlayUrlInput.select();
document.execCommand('copy');
copyButton.textContent = 'Copied!';
setTimeout(() => { copyButton.textContent = 'Copy'; }, 2000);
});
// --- Theme Selector Logic ---
const themeSelect = document.getElementById('theme-select');
const previewFrame = document.getElementById('theme-preview-frame');
const baseUrl = "{{ settings.APP_BASE_URL }}";
const userId = "{{ user.id }}";
// Function to update preview and save settings
function updateTheme(selectedTheme) {
// Update iframe preview
previewFrame.src = `${baseUrl}/overlay/${userId}?theme_override=${selectedTheme}`;
// Save the setting to the backend
fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ overlay_theme: selectedTheme }),
})
.then(response => response.json())
.then(data => {
console.log('Settings saved:', data.message);
})
.catch(error => console.error('Error saving settings:', error));
}
// Event listener for dropdown change
themeSelect.addEventListener('change', (event) => {
updateTheme(event.target.value);
});
// Set initial preview on page load
updateTheme(themeSelect.value);
// --- Custom Theme Creation Logic ---
const themeForm = document.getElementById('theme-form');
themeForm.addEventListener('submit', function(event) {
event.preventDefault();
const formData = new FormData(themeForm);
const data = Object.fromEntries(formData.entries());
fetch('/api/themes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
.then(response => response.json())
.then(newTheme => {
// Add new theme to the list and dropdown without reloading
addThemeToList(newTheme);
addThemeToSelect(newTheme);
themeForm.reset(); // Clear the form
})
.catch(error => console.error('Error creating theme:', error));
});
// --- Custom Theme Deletion Logic ---
const themesListContainer = document.getElementById('custom-themes-list');
themesListContainer.addEventListener('click', function(event) {
if (event.target.classList.contains('delete-theme-btn')) {
const themeId = event.target.dataset.themeId;
if (confirm('Are you sure you want to delete this theme?')) {
fetch(`/api/themes/${themeId}`, { method: 'DELETE' })
.then(response => {
if (response.ok) {
// Remove theme from the list and dropdown
document.getElementById(`theme-item-${themeId}`).remove();
document.querySelector(`#theme-select option[value="custom-${themeId}"]`).remove();
}
})
.catch(error => console.error('Error deleting theme:', error));
}
}
});
function addThemeToList(theme) {
const list = document.getElementById('custom-themes-list');
const item = document.createElement('div');
item.className = 'custom-theme-item';
item.id = `theme-item-${theme.id}`;
item.innerHTML = `<span>${theme.name}</span><button class="delete-theme-btn" data-theme-id="${theme.id}">Delete</button>`;
list.appendChild(item);
}
function addThemeToSelect(theme) {
let optgroup = document.querySelector('#theme-select optgroup[label="Your Themes"]');
if (!optgroup) {
optgroup = document.createElement('optgroup');
optgroup.label = 'Your Themes';
themeSelect.appendChild(optgroup);
}
const option = document.createElement('option');
option.value = `custom-${theme.id}`;
option.textContent = theme.name;
optgroup.appendChild(option);
}
});
</script>
{% endblock %}

46
templates/help_css.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}CSS Overlay Help - {{ super() }}{% endblock %}
{% block content %}
<div class="card">
<h2>Custom Overlay CSS Guide</h2>
<p>This guide will help you create your own custom CSS for the chat overlay. Your CSS is applied on top of a base stylesheet, so you only need to override the styles you want to change.</p>
</div>
<div class="card">
<h3>HTML Structure</h3>
<p>Your CSS will be applied to a simple HTML structure. Here are the key classes you can target:</p>
<ul>
<li><code>.chat-container</code>: The main container holding all messages.</li>
<li><code>.chat-message</code>: The container for a single message, including the username and text.</li>
<li><code>.username</code>: The part of the message that shows the chatter's name.</li>
<li><code>.message-text</code>: The actual content of the chat message.</li>
</ul>
<p>The <code>body</code> of the overlay is transparent by default, so you only need to style the message elements.</p>
</div>
<div class="card">
<h3>Example: "Bubbly" Theme</h3>
<p>Here is a simple example to get you started. This creates chat bubbles with a gradient background.</p>
<pre><code>body {
font-family: 'Comic Sans MS', cursive, sans-serif;
font-size: 18px;
text-shadow: 1px 1px 2px #333;
}
.chat-message {
background: linear-gradient(135deg, #7f5af0, #a970ff);
color: white;
padding: 10px 15px;
border-radius: 20px;
margin-bottom: 10px;
display: inline-block;
}
.username {
font-weight: bold;
color: #e0e0e0;
}</code></pre>
</div>
{% endblock %}

16
templates/login.html Normal file
View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Login - MultiChat Overlay{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-box">
<h1>MultiChat Overlay</h1>
<p>Connect your streaming accounts to get started.</p>
<a href="/login/twitch" class="btn-primary twitch-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 10px;"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0h1.714v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"></path></svg>
Login with Twitch
</a>
</div>
</div>
{% endblock %}

View File

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chat Overlay - Bright Green</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-bright-green.css') }}">
</head>
<body>
<div class="chat-container">
<div class="chat-message"><span class="username">Streamer:</span> This is the bright green theme!</div>
<div class="chat-message"><span class="username">VerdantViewer:</span> Looks so fresh and clean!</div>
<div class="chat-message"><span class="username">NightBot:</span> Welcome to the channel!</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Chat Overlay</title>
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-base.css') }}">
<link rel="stylesheet" href="/css/custom/{{ theme_id }}">
</head>
<body>
<div class="chat-container">
<!-- Chat messages will be injected here by the WebSocket client -->
</div>
</body>
</html>

View File

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chat Overlay - Dark Purple</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-dark-purple.css') }}">
</head>
<body>
<div class="chat-container">
<div class="chat-message"><span class="username">Streamer:</span> This is the dark purple theme!</div>
<div class="chat-message"><span class="username">PurpleHaze:</span> Very cool, feels like Twitch!</div>
<div class="chat-message"><span class="username">NightBot:</span> Welcome to the channel!</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hacker Green Overlay</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<style>
body {
background-color: transparent;
color: #0f0;
font-family: 'VT323', monospace;
font-size: 20px;
margin: 0;
padding: 10px;
overflow: hidden;
text-shadow: 0 0 5px #0f0;
}
.chat-container {
display: flex;
flex-direction: column;
gap: 5px;
}
.username {
font-weight: bold;
}
</style>
</head>
<body>
<div id="chat-container" class="chat-container">
<!-- Messages will be injected here -->
</div>
<script>
const userId = window.location.pathname.split('/')[2];
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/${userId}`);
ws.onmessage = function(event) {
const messageData = JSON.parse(event.data);
const chatContainer = document.getElementById('chat-container');
const messageElement = document.createElement('div');
messageElement.innerHTML = `<span class="username">${messageData.author}:</span> ${messageData.text}`;
chatContainer.appendChild(messageElement);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Minimal Light Overlay</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
body {
background-color: rgba(0, 0, 0, 0);
color: #111827;
font-family: 'Inter', sans-serif;
font-size: 16px;
margin: 0;
padding: 10px;
overflow: hidden;
}
.chat-message {
background-color: rgba(255, 255, 255, 0.8);
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
backdrop-filter: blur(4px);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.username {
font-weight: 600;
}
</style>
</head>
<body>
<!-- Example Message -->
<div class="chat-message">
<span class="username">User:</span>
<span class="message">This is the minimal light theme.</span>
</div>
</body>
</html>

42
templates/overlay.html Normal file
View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chat Overlay</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
body {
background-color: transparent;
color: white;
font-family: 'Inter', sans-serif;
font-size: 16px;
margin: 0;
padding: 10px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.chat-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-message {
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.4);
border-radius: 4px;
}
.username {
font-weight: 800;
color: #a970ff; /* A nice purple for usernames */
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-message"><span class="username">User123:</span> This is an example chat message!</div>
<div class="chat-message"><span class="username">StreamFan:</span> Looks great! Can't wait to use this.</div>
<div class="chat-message"><span class="username">AnotherViewer:</span> Hello world!</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Test Overlay</title>
<style>
body {
background-color: black;
color: white;
font-family: sans-serif;
margin: 0;
padding: 20px;
}
.message {
margin-bottom: 10px;
}
</style>
</head>
<body>
<h1>Live Test Overlay</h1>
<div id="chat-messages">
<!-- Messages will appear here -->
</div>
<script>
// JavaScript to connect to WebSocket and display messages will go here
</script>
</body>
</html>

22
websocket_manager.py Normal file
View File

@@ -0,0 +1,22 @@
from typing import Dict, List
from fastapi import WebSocket
class WebSocketManager:
def __init__(self):
# Maps user_id to a list of active WebSocket connections
self.active_connections: Dict[int, List[WebSocket]] = {}
print("WebSocketManager initialized.")
async def connect(self, user_id: int, websocket: WebSocket):
await websocket.accept()
if user_id not in self.active_connections:
self.active_connections[user_id] = []
self.active_connections[user_id].append(websocket)
def disconnect(self, user_id: int, websocket: WebSocket):
self.active_connections[user_id].remove(websocket)
async def broadcast_to_user(self, user_id: int, message: dict):
if user_id in self.active_connections:
for connection in self.active_connections[user_id]:
await connection.send_json(message)