87 Commits

Author SHA1 Message Date
ba480e8409 Working on: The Twitch authentication 2025-12-25 21:22:24 +01:00
e69d423deb Working on: The Twitch authentication 2025-12-25 21:05:11 +01:00
773288faf0 Working on: The Twitch authentication 2025-12-25 18:40:54 +01:00
7a18b5b402 Working on: The Twitch authentication 2025-12-25 18:39:14 +01:00
cfc082a6f8 Working on: The Twitch authentication 2025-12-25 18:34:44 +01:00
8521510215 Working on: The Twitch authentication 2025-12-25 18:22:46 +01:00
4b640861a6 rftdgsh 2025-11-18 01:57:12 +01:00
7748e55a71 sdfghb 2025-11-18 01:53:39 +01:00
edfe38113a edrsfx 2025-11-18 01:51:49 +01:00
c249010cbd ergqa 2025-11-18 01:50:12 +01:00
1650f9343e ruining a good night's sleep 2025-11-18 01:48:36 +01:00
12c69f4797 oauth token mis-read 2025-11-18 01:47:39 +01:00
845e74bd5e ssl back on 2025-11-18 01:45:53 +01:00
1c93a09c74 dsf 2025-11-18 01:43:43 +01:00
53c1966494 fucked 2025-11-18 01:40:58 +01:00
303c8430e8 re-formatting client_ID and client__Secret 2025-11-18 01:37:17 +01:00
afa27271fa Swapped twitch connector twitchio.Client 2025-11-18 01:33:19 +01:00
45a511b8e9 debug text in logs 2025-11-18 01:18:54 +01:00
67ae5065f5 reverted bot_id 2025-11-18 01:17:27 +01:00
8abb76a55b Later bot start 2025-11-18 01:14:42 +01:00
555ba5a2d0 garrr! 2025-11-18 01:13:08 +01:00
c6450bc7be test3 on the listener 2025-11-18 01:11:18 +01:00
9eb614b9a3 Incorrectly passed user_id corrected to bot_id and id=bot_id 2025-11-18 01:09:22 +01:00
c52560aaae repairing listener 2025-11-18 01:01:46 +01:00
aea75918b2 Trying to bring chat messages past proxy by ensuring SSL traffic 2025-11-18 00:58:11 +01:00
a0877d8276 proxy handling of css 2025-11-18 00:55:32 +01:00
4b96b17368 css moar correction 2025-11-18 00:53:12 +01:00
e74553a482 Trying to bring back default css 2025-11-18 00:49:06 +01:00
2fffb4318a -- 2025-11-18 00:45:51 +01:00
08526cfa60 Re-arranged call of static/ 2025-11-18 00:44:01 +01:00
2392d21e17 Small correction in main.py 2025-11-18 00:39:41 +01:00
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
38 changed files with 1621 additions and 112 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.
@@ -130,7 +122,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) )
---
@@ -140,10 +132,6 @@ If you want to use emojis for visibility, here's some I have used:
### To Do
* `[ ]` **1.4: Basic Session Management:** Create a simple session/JWT system to know *who* is logged in.
### In Progress
### Done
* `[✔️]` **1.0: Project Skeleton** - @ramforth
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
@@ -151,39 +139,44 @@ If you want to use emojis for visibility, here's some I have used:
* `[✔️]` **1.1.5: Discord Overview:** Create an automated 'TASK-LIST' and post to Discord whenever someone pushes a change to the repository. @ramforth
* `[✔️]` **1.2: Twitch OAuth API:** Create FastAPI endpoints for `/login/twitch` (redirect) and `/auth/twitch/callback` (handles token exchange). @ramforth
* `[✔️]` **1.3: Secure Token Storage:** Implement helper functions to `encrypt` and `decrypt` OAuth tokens before storing them in the database. @ramforth
* `[✔️]` **1.4: Basic Session Management:** Create a simple session/JWT system to know *who* is logged in. @ramforth
* `[✔️]` **1.5: Login Frontend:** Create a basic `login.html` file with a "Login with Twitch" button. @ramforth
---
## ⏳ 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.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).
```

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.

61
FAILED_APPROACH_6.md Normal file
View File

@@ -0,0 +1,61 @@
# Analysis of Failed Approach #6: `twitchio` Initialization Conflict
## 1. Executive Summary
This document outlines the reasons for the persistent failure to initialize the `twitchio` chat listeners within our FastAPI application. Our attempts have been caught in a cyclical error pattern, switching between a `TypeError` for a missing argument and an `OSError` for a port conflict.
The root cause is a fundamental design conflict: we are attempting to use a high-level abstraction (`twitchio.ext.commands.Bot`) in a way it is not designed for. This class is architected as a **standalone application** that includes its own web server for handling Twitch EventSub. Our project requires a simple, "headless" IRC client component to be embedded within our existing FastAPI web server. The `commands.Bot` class is not this component, and our attempts to force it into this role have failed.
**Conclusion:** Continuing to patch this approach is inefficient and unreliable. A new strategy is required.
## 2. The Cyclical Error Pattern
Our efforts have resulted in a loop between three primary, contradictory errors, demonstrating the library's conflicting internal states.
### Error A: `OSError: [Errno 98] address already in use`
* **Trigger:** Occurs with a default `twitchio.ext.commands.Bot` initialization.
* **Implication:** The library, by default, attempts to start its own `AiohttpAdapter` web server (e.g., on port 4343) for EventSub, which immediately conflicts with our main Uvicorn process or any other service.
### Error B: `TypeError: Bot.__init__() missing 1 required keyword-only argument: 'bot_id'`
* **Trigger:** Occurs when we successfully disable the internal web server using a `NullAdapter`.
* **Implication:** By disabling the web server, we seem to place the `Bot` into a different initialization path that now strictly requires the `bot_id` argument, which it previously did not.
### Error C: Back to `OSError: [Errno 98] address already in use`
* **Trigger:** Occurs when we satisfy Error B by providing the `bot_id` while the `NullAdapter` is active.
* **Implication:** This is the most critical failure. It demonstrates that providing the `bot_id` causes the library's constructor to **ignore our `NullAdapter`** and fall back to its default behavior of starting a web server, thus bringing us back to Error A.
### Error D: `TypeError: Client.start() got an unexpected keyword argument 'web_server'`
* **Trigger:** Occurred when we attempted to bypass the adapter system entirely and use `bot.start(web_server=False)`.
* **Implication:** This proves the `start()` method's API does not support this parameter, closing another potential avenue for controlling the library's behavior.
## 3. The Homelab & Nginx Proxy Manager Conflict
This architectural mismatch is especially problematic in our homelab environment using Nginx Proxy Manager.
1. **Single Entry Point:** Our architecture is designed for a single entry point. Nginx Proxy Manager accepts all traffic on ports 80/443 and forwards it to a single backend service: our FastAPI application on port 8000.
2. **Unwanted Second Service:** `twitchio`'s attempt to start a second web server on a different port (e.g., 4343) is fundamentally incompatible with this model. It forces us to treat our single Python application as two distinct backend services.
3. **Unnecessary Complexity:** To make this work, we would have to configure Nginx Proxy Manager with complex location-based routing rules (e.g., route `/` to port 8000, but route `/eventsub` to port 4343). This is brittle, hard to maintain, and completely unnecessary for our goal, which is **IRC chat only**.
4. **Port Conflicts:** In a managed homelab environment (using Docker, etc.), ports are explicitly allocated resources. A library that randomly tries to bind to an arbitrary port is an unstable and unpredictable component that will inevitably conflict with other services.
## 4. Root Cause: Architectural Mismatch
The `twitchio.ext.commands.Bot` class is a powerful, feature-rich tool designed for building **standalone bots**. It is not designed to be a simple, embeddable component within a larger web application that has its own server.
Our application architecture requires a "headless" IRC client—a component that does nothing but connect to Twitch's chat servers and listen for messages. The `commands.Bot` class is not that component. It brings along a suite of other features, including its own web server, which we cannot reliably disable.
Our attempts to "trick" the library into behaving like a simple client have failed because we are fighting against its core design:
## 5. Recommendation: Pivot to a Low-Level Client
We must abandon the use of `twitchio.ext.commands.Bot`.
The correct path forward is to use the lower-level `twitchio.Client` class instead. This class is designed to be a more fundamental IRC client without the high-level command handling and, crucially, without the tightly coupled web server components.
By switching to `twitchio.Client`, we can build a `TwitchBot` class that is truly "headless" and will integrate cleanly into our existing FastAPI application and `ListenerManager` without causing port conflicts or argument mismatches. This aligns our implementation with our architectural needs.

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).

12
auth.py
View File

@@ -1,5 +1,6 @@
import httpx
import secrets
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
@@ -11,6 +12,9 @@ import security
router = APIRouter()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Dependency to get a DB session
def get_db():
db = SessionLocal()
@@ -27,6 +31,7 @@ async def login_with_twitch(request: Request):
# Generate a random state token for CSRF protection
state = secrets.token_urlsafe(16)
request.session['oauth_state'] = state
logger.info(f"Generated OAuth state: {state} for session.")
# As per RESEARCH_REPORT.md, these are the minimum required scopes
scopes = "chat:read"
@@ -48,7 +53,9 @@ async def auth_twitch_callback(code: str, state: str, request: Request, db: Sess
Step 2 of OAuth flow: Handle the callback from Twitch after user authorization.
"""
# CSRF Protection: Validate the state
if state != request.session.pop('oauth_state', None):
session_state = request.session.pop('oauth_state', None)
if state != session_state:
logger.error(f"OAuth state mismatch! Received state: '{state}', Session state: '{session_state}'")
raise HTTPException(status_code=403, detail="Invalid state parameter. CSRF attack suspected.")
# Step 4: Exchange the authorization code for an access token
@@ -99,6 +106,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()

80
chat_listener.py Normal file
View File

@@ -0,0 +1,80 @@
import logging
import twitchio
from sqlalchemy.orm import Session
from database import SessionLocal
import models
import security
logger = logging.getLogger(__name__)
class TwitchBot(twitchio.Client):
def __init__(self, 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
self.is_initialized = False # Health check flag
async def start(self, access_token: str, refresh_token: str, client_id: str, client_secret: str, channel_name: str):
"""
A custom start method that also handles initialization. This makes the
entire setup process an awaitable, atomic operation.
"""
logger.info(f"DIAGNOSTIC: Initializing and connecting for user {self.db_user_id}...")
# The sensitive __init__ call is now inside the awaitable task.
# FIX: Do not pass client_secret/refresh_token to super().__init__.
# This prevents twitchio from starting its internal web server (AiohttpAdapter) on port 4343,
# which causes "Address already in use" errors when multiple bots run.
super().__init__(token=access_token, client_id=client_id, initial_channels=[channel_name], ssl=True)
self.channel_name = channel_name
self.is_initialized = True
try:
await super().start()
except Exception as e:
logger.error(f"Twitch connection failed for user {self.db_user_id}: {e}")
async def event_ready(self):
"""Called once when the bot goes online."""
# Diagnostic Logging: Confirming the bot is ready and joined the channel.
logger.info(f"DIAGNOSTIC: Listener connected and ready for user_id: {self.db_user_id}, channel: #{self.channel_name}")
async def event_token_refresh(self, token: str, refresh_token: str):
"""
Called when twitchio automatically refreshes the token.
We must save the new tokens back to our database.
"""
logger.info(f"DIAGNOSTIC: Token refreshed for user {self.db_user_id}. Saving new tokens to database.")
db: Session = SessionLocal()
try:
user = db.query(models.User).filter(models.User.id == self.db_user_id).first()
if user:
user.encrypted_tokens = security.encrypt_tokens(token, refresh_token)
db.commit()
finally:
db.close()
async def event_message(self, message): # Mandate: Type hint removed to prevent import errors.
"""Runs every time a message is sent in chat."""
# Diagnostic Logging: Checkpoint 1 - A raw message is received from Twitch.
logger.info(f"DIAGNOSTIC: Message received for user {self.db_user_id} in channel {self.channel_name}: '{message.content}'")
# 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 if message.author else "Twitch",
"text": message.content,
"platform": "twitch"
}
# Diagnostic Logging: Checkpoint 2 - The message data has been prepared for broadcasting.
logger.info(f"DIAGNOSTIC: Prepared chat_data for user {self.db_user_id}: {chat_data}")
# 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)
# Diagnostic Logging: Checkpoint 3 - The broadcast function was called.
logger.info(f"DIAGNOSTIC: Broadcast called for user {self.db_user_id}.")

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>

75
filename Normal file
View File

@@ -0,0 +1,75 @@
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, Dict] = {}
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
# Guard Clause: Ensure the user has a valid platform ID required by twitchio.
if not user.platform_user_id:
print(f"ERROR: Cannot start listener for user {user.id}. Missing platform_user_id.")
return
print(f"Starting listener for user {user.id} ({user.username})...")
try:
tokens = security.decrypt_tokens(user.encrypted_tokens)
access_token = tokens['access_token']
refresh_token = tokens['refresh_token']
# Initialize the bot object without credentials first.
bot = TwitchBot(
websocket_manager=websocket_manager,
db_user_id=user.id
)
# Create a task that runs our new start method with all credentials.
# If super().__init__ fails inside bot.start(), the exception will be
# caught by our try/except block here.
task = asyncio.create_task(bot.start(
access_token=access_token, refresh_token=refresh_token,
client_id=settings.TWITCH_CLIENT_ID, client_secret=settings.TWITCH_CLIENT_SECRET,
channel_name=user.username
))
# Store both the task and the bot instance for graceful shutdown
self.active_listeners[user.id] = {"task": task, "bot": bot}
except Exception as e:
# This will catch errors during bot instantiation (e.g., bad token)
print(f"ERROR: Failed to instantiate or start listener for user {user.id}: {e}")
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}...")
listener_info = self.active_listeners.pop(user_id)
task = listener_info["task"]
bot = listener_info["bot"]
# Gracefully close the bot's connection
if bot and not bot.is_closed():
await bot.close()
# Cancel the asyncio task
task.cancel()
try:
await task
except asyncio.CancelledError:
print(f"Listener for user {user_id} successfully stopped.")

79
listener_manager.py Normal file
View File

@@ -0,0 +1,79 @@
import asyncio
import logging
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
logger = logging.getLogger(__name__)
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, Dict] = {}
logger.info("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:
logger.info(f"Listener for user {user.id} is already running.")
return
# Guard Clause: Ensure the user has a valid platform ID required by twitchio.
if not user.platform_user_id:
logger.error(f"Cannot start listener for user {user.id}. Missing platform_user_id.")
return
logger.info(f"Starting listener for user {user.id} ({user.username})...")
try:
tokens = security.decrypt_tokens(user.encrypted_tokens)
access_token = tokens['access_token']
refresh_token = tokens['refresh_token']
# Initialize the bot object without credentials first. It's just a lightweight container.
bot = TwitchBot(
websocket_manager=websocket_manager,
db_user_id=user.id
)
# Create a task that runs our new start method with all credentials.
# If super().__init__ fails inside bot.start(), the exception will be
# caught by our try/except block here, preventing hollow objects.
task = asyncio.create_task(bot.start(
access_token=access_token, refresh_token=refresh_token,
client_id=settings.TWITCH_CLIENT_ID, client_secret=settings.TWITCH_CLIENT_SECRET,
channel_name=user.username
))
# Store both the task and the bot instance for graceful shutdown
self.active_listeners[user.id] = {"task": task, "bot": bot}
except Exception as e:
# This will catch errors during bot instantiation (e.g., bad token)
logger.error(f"Failed to instantiate or start listener for user {user.id}: {e}")
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:
logger.info(f"No active listener found for user {user_id}.")
return
logger.info(f"Stopping listener for user {user_id}...")
listener_info = self.active_listeners.pop(user_id)
task = listener_info["task"]
bot = listener_info["bot"]
# Gracefully close the bot's connection
# The getattr check prevents the shutdown crash if the bot was never initialized.
if bot and getattr(bot, 'is_initialized', False) and not bot.is_closed():
await bot.close()
# Cancel the asyncio task
task.cancel()
try:
await task
except asyncio.CancelledError:
logger.info(f"Listener for user {user_id} successfully stopped.")

0
main.css Normal file
View File

180
main.py
View File

@@ -1,15 +1,24 @@
import os
from fastapi import FastAPI, Request, Depends
from starlette.middleware.sessions import SessionMiddleware
from starlette.staticfiles import StaticFiles
import logging
import asyncio
from fastapi import FastAPI, Request, Depends, HTTPException
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
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,36 +26,71 @@ 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")
# --- Logging Configuration ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def background_listener_startup(app: FastAPI):
"""A non-blocking task to start listeners after the app has started."""
logger.info("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:
logger.error(f"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...")
logger.info("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.")
logger.info("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
logger.info("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)
# Configure Jinja2 templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)
# Add session middleware. A secret key is required for signing the session cookie.
# We can reuse our encryption key for this, but in production you might want a separate key.
# Note: Middleware is applied in reverse order (last added is first executed).
# We want ProxyHeaders to run FIRST (outermost) to fix the scheme/host,
# then SessionMiddleware to run SECOND (inner) so it sees the correct scheme.
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
# Mount the 'static' directory using an absolute path for reliability
# This MUST be done before the routes that depend on it are defined.
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Add the authentication router
app.include_router(auth.router)
# Add session middleware. A secret key is required for signing the session cookie.
# We can reuse our encryption key for this, but in production you might want a separate key.
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
# --- Template Dependency ---
def get_templates():
return Jinja2Templates(directory=TEMPLATES_DIR)
@app.get("/")
async def read_root():
return FileResponse(os.path.join(STATIC_DIR, "login.html"))
async def read_root(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse("login.html", {"request": request})
@app.get("/dashboard")
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db),
templates: Jinja2Templates = Depends(get_templates)):
# This is our protected route. It checks if a user_id exists in the session.
user_id = request.session.get('user_id')
if not user_id:
@@ -55,11 +99,113 @@ 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, templates: Jinja2Templates = Depends(get_templates)):
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), templates: Jinja2Templates = Depends(get_templates)):
# 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)
logger.info(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

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

@@ -0,0 +1,290 @@
/* --- 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>

63
twitch_test.py Normal file
View File

@@ -0,0 +1,63 @@
import asyncio
import os
from dotenv import load_dotenv
import twitchio
from twitchio.ext import commands
# --- Standalone Twitch IRC Connection Test ---
class TestBot(twitchio.Client):
"""A minimal twitchio client for testing IRC connectivity."""
def __init__(self, channel_name: str):
# Load credentials from environment variables
self.TMI_TOKEN = os.getenv("TWITCH_TEST_TOKEN")
self.CLIENT_ID = os.getenv("TWITCH_CLIENT_ID")
self.TARGET_CHANNEL = channel_name
# Pre-flight checks
if not all([self.TMI_TOKEN, self.CLIENT_ID, self.TARGET_CHANNEL]):
raise ValueError("Missing required environment variables. Ensure TWITCH_TEST_TOKEN, TWITCH_CLIENT_ID, and a channel are provided.")
print("--- Configuration ---")
print(f"CLIENT_ID: {self.CLIENT_ID[:4]}...{self.CLIENT_ID[-4:]}")
print(f"TOKEN: {self.TMI_TOKEN[:12]}...")
print(f"TARGET CHANNEL: {self.TARGET_CHANNEL}")
print("-----------------------")
super().__init__(
token=f"oauth:{self.TMI_TOKEN}",
client_id=self.CLIENT_ID,
initial_channels=[self.TARGET_CHANNEL],
ssl=True
)
async def event_ready(self):
"""Called once when the bot goes online."""
print("\n--- Connection Successful ---")
print(f"Logged in as: {self.nick}")
print(f"Listening for messages in #{self.TARGET_CHANNEL}...")
print("---------------------------\n")
async def event_message(self, message):
"""Runs every time a message is sent in chat."""
if message.echo:
return
print(f"#{message.channel.name} | {message.author.name}: {message.content}")
async def main():
"""Main function to run the test bot."""
# IMPORTANT: Replace 'ramforth' with the channel you want to test.
channel_to_test = "ramforth"
print(f"Attempting to connect to Twitch IRC for channel: {channel_to_test}")
try:
bot = TestBot(channel_name=channel_to_test)
await bot.start()
except Exception as e:
print(f"\n--- AN ERROR OCCURRED ---")
print(f"Error: {e}")
print("Please check your credentials and network connection.")
if __name__ == "__main__":
asyncio.run(main())

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)