mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
Rewrite dashboard and overlay, single /events stream, static client served by the backend
Backend: one SSE stream for messages, selection, poll and connection status; reconnect with backoff; no on-disk Innertube session cache; mock only with MOCK_CHAT=1; binds to 127.0.0.1; serves client/out so production runs on one port. Full URLs for links YouTube truncates in chat. Client: components split out, Lucide icons, stream title and status, on-air strip, search, pause, keyboard shortcuts, overlay settings dialog. Overlay themes, position, size, animation and auto-hide via URL, sized for 1080p and scaled with the source. New blueprint theme for the brand background. Removed Tailwind, better-sqlite3, zod, the timezone helpers, Cline memory bank and AGENTS.md. Added ESLint, node:test tests and CI.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
+2
-1
@@ -1,5 +1,4 @@
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
.env.local
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -33,3 +32,5 @@ node_modules/
|
||||
# Task files and project management
|
||||
.taskmaster/
|
||||
prd.txt
|
||||
.pnpm-store
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# Cline's Memory Bank
|
||||
|
||||
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
|
||||
|
||||
## Memory Bank Structure
|
||||
|
||||
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
|
||||
|
||||
flowchart TD
|
||||
PB[projectbrief.md] --> PC[productContext.md]
|
||||
PB --> SP[systemPatterns.md]
|
||||
PB --> TC[techContext.md]
|
||||
|
||||
PC --> AC[activeContext.md]
|
||||
SP --> AC
|
||||
TC --> AC
|
||||
|
||||
AC --> P[progress.md]
|
||||
|
||||
### Core Files (Required)
|
||||
|
||||
1. `projectbrief.md`
|
||||
- Foundation document that shapes all other files
|
||||
- Created at project start if it doesn't exist
|
||||
- Defines core requirements and goals
|
||||
- Source of truth for project scope
|
||||
|
||||
2. `productContext.md`
|
||||
- Why this project exists
|
||||
- Problems it solves
|
||||
- How it should work
|
||||
- User experience goals
|
||||
|
||||
3. `activeContext.md`
|
||||
- Current work focus
|
||||
- Recent changes
|
||||
- Next steps
|
||||
- Active decisions and considerations
|
||||
- Important patterns and preferences
|
||||
- Learnings and project insights
|
||||
|
||||
4. `systemPatterns.md`
|
||||
- System architecture
|
||||
- Key technical decisions
|
||||
- Design patterns in use
|
||||
- Component relationships
|
||||
- Critical implementation paths
|
||||
|
||||
5. `techContext.md`
|
||||
- Technologies used
|
||||
- Development setup
|
||||
- Technical constraints
|
||||
- Dependencies
|
||||
- Tool usage patterns
|
||||
|
||||
6. `progress.md`
|
||||
- What works
|
||||
- What's left to build
|
||||
- Current status
|
||||
- Known issues
|
||||
- Evolution of project decisions
|
||||
|
||||
### Additional Context
|
||||
|
||||
Create additional files/folders within memory-bank/ when they help organize:
|
||||
|
||||
- Complex feature documentation
|
||||
- Integration specifications
|
||||
- API documentation
|
||||
- Testing strategies
|
||||
- Deployment procedures
|
||||
|
||||
## Core Workflows
|
||||
|
||||
### Plan Mode
|
||||
|
||||
flowchart TD
|
||||
Start[Start] --> ReadFiles[Read Memory Bank]
|
||||
ReadFiles --> CheckFiles{Files Complete?}
|
||||
|
||||
CheckFiles -->|No| Plan[Create Plan]
|
||||
Plan --> Document[Document in Chat]
|
||||
|
||||
CheckFiles -->|Yes| Verify[Verify Context]
|
||||
Verify --> Strategy[Develop Strategy]
|
||||
Strategy --> Present[Present Approach]
|
||||
|
||||
### Act Mode
|
||||
|
||||
flowchart TD
|
||||
Start[Start] --> Context[Check Memory Bank]
|
||||
Context --> Update[Update Documentation]
|
||||
Update --> Execute[Execute Task]
|
||||
Execute --> Document[Document Changes]
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Memory Bank updates occur when:
|
||||
|
||||
1. Discovering new project patterns
|
||||
2. After implementing significant changes
|
||||
3. When user requests with **update memory bank** (MUST review ALL files)
|
||||
4. When context needs clarification
|
||||
|
||||
flowchart TD
|
||||
Start[Update Process]
|
||||
|
||||
subgraph Process
|
||||
P1[Review ALL Files]
|
||||
P2[Document Current State]
|
||||
P3[Clarify Next Steps]
|
||||
P4[Document Insights & Patterns]
|
||||
|
||||
P1 --> P2 --> P3 --> P4
|
||||
end
|
||||
|
||||
Start --> Process
|
||||
|
||||
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
|
||||
|
||||
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
||||
@@ -1,55 +1,25 @@
|
||||
# YouTube Live Chat Client (WIP)
|
||||
# YTChatHub
|
||||
|
||||
Build a desktop-operated, open-source YouTube Live chat client that delivers a fast, reliable stream of messages and allows a streamer to spotlight a selected chat message on an OBS-ready overlay.
|
||||

|
||||
|
||||
Currently work in progress bugs might be there.
|
||||
Open-source YouTube Live chat client for streamers. Watch the chat in a fast dashboard, click a message and it appears on an OBS browser-source overlay. Super Chats, super stickers, memberships, gifted memberships, badges, custom emojis and the chat leaderboard are all shown. Long links in chat arrive with their full URL, not the truncated text YouTube displays.
|
||||
|
||||
## Key Features
|
||||
## Running it
|
||||
|
||||
- **High-Performance Chat**: A sleek, minimal dashboard for monitoring YouTube Live chat in real-time.
|
||||
- **OBS Integration**: Select any message to instantly display it on an OBS-ready overlay.
|
||||
- **Rich Message Support**: Full support for Super Chats, gifted memberships, and user badges.
|
||||
- **Compact and Efficient**: A dark, minimalist UI designed to be space-efficient and easy on the eyes.
|
||||
- **Intelligent Auto-Scroll**: The chat automatically scrolls to new messages but stops when you scroll up to read previous messages.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Follow these instructions to set up the project for local development.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v20.x or later)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yusufipk/YTChatHub
|
||||
cd YTChatHub
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
To start the development server for both the backend and frontend, run:
|
||||
Requires Node.js 20+ and pnpm.
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
- The dashboard will be available at `http://localhost:3000/dashboard`.
|
||||
- The OBS overlay will be available at `http://localhost:3000/overlay`.
|
||||
Open `http://localhost:4100`, paste the live stream URL or video id, and add the overlay URL from the dashboard's Overlay button as an OBS Browser Source. The overlay's theme, position, size, animation and auto-hide are set in that URL, so different sources can look different.
|
||||
|
||||
## Technology Stack
|
||||
`pnpm dev` runs the backend on 4100 and the Next.js client on 3100 with hot reload. Optional environment variables: `YOUTUBE_LIVE_ID` connects at startup, `PORT` and `HOST` change where the backend listens (it binds to 127.0.0.1 by default), `MOCK_CHAT=1` generates fake messages when nothing is connected.
|
||||
|
||||
- **Frontend**: [Next.js](https://nextjs.org/) (React)
|
||||
- **Backend**: [Node.js](https://nodejs.org/) with [Fastify](https://www.fastify.io/) and [tsx](https://github.com/esbuild-kit/tsx)
|
||||
- **YouTube Integration**: [youtubei.js](https://github.com/LuanRT/YouTube.js)
|
||||
- **Styling**: Handcrafted CSS
|
||||
Keyboard: `/` focuses search, `P` pauses the chat, `Esc` clears the overlay.
|
||||
|
||||
## Stack
|
||||
|
||||
Fastify backend with [youtubei.js](https://github.com/LuanRT/YouTube.js) for chat ingestion, one Server-Sent Events stream for messages, selection and connection status, and a Next.js static export served by the same backend. Chat runs entirely on your machine; YouTube CDN images are proxied and cached locally so OBS never hits rate limits.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type CachedImage = { buffer: Buffer; contentType: string; timestamp: number };
|
||||
|
||||
const ALLOWED_HOSTS = ['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com', 'lh3.googleusercontent.com'];
|
||||
const CACHE_TTL = 1000 * 60 * 60 * 24;
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
|
||||
|
||||
const imageCache = new Map<string, CachedImage>();
|
||||
|
||||
function sendImage(reply: FastifyReply, image: CachedImage) {
|
||||
reply.header('Content-Type', image.contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
return reply.send(image.buffer);
|
||||
}
|
||||
|
||||
/** GET /proxy/image?url= fetches YouTube CDN images through the backend so the browser does not hit CDN rate limits. */
|
||||
export function registerImageProxy(fastify: FastifyInstance): void {
|
||||
fastify.get<{ Querystring: { url?: string } }>('/proxy/image', async (request, reply) => {
|
||||
const { url } = request.query;
|
||||
if (!url || typeof url !== 'string') {
|
||||
reply.status(400);
|
||||
return { error: 'url parameter is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ALLOWED_HOSTS.includes(new URL(url).hostname)) {
|
||||
reply.status(403);
|
||||
return { error: 'Only YouTube CDN and Google User Content URLs are allowed' };
|
||||
}
|
||||
} catch {
|
||||
reply.status(400);
|
||||
return { error: 'Invalid URL' };
|
||||
}
|
||||
|
||||
const cacheKey = crypto.createHash('md5').update(url).digest('hex');
|
||||
const cached = imageCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return sendImage(reply, cached);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Referer: 'https://www.youtube.com/' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
console.warn('[Backend] Rate limited by YouTube CDN for:', url);
|
||||
if (cached) return sendImage(reply, cached);
|
||||
}
|
||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
}
|
||||
|
||||
const image: CachedImage = {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: response.headers.get('content-type') || 'image/jpeg',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
imageCache.set(cacheKey, image);
|
||||
|
||||
if (imageCache.size > MAX_CACHE_SIZE) {
|
||||
// Drop the oldest 20% so eviction is not a per-request cost.
|
||||
const entries = Array.from(imageCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
for (const [key] of entries.slice(0, Math.floor(MAX_CACHE_SIZE * 0.2))) imageCache.delete(key);
|
||||
}
|
||||
|
||||
return sendImage(reply, image);
|
||||
} catch (error) {
|
||||
console.error('[Backend] Failed to proxy image:', error);
|
||||
// A stale copy beats a broken avatar.
|
||||
if (cached) return sendImage(reply, cached);
|
||||
reply.status(500);
|
||||
return { error: 'Failed to fetch image' };
|
||||
}
|
||||
});
|
||||
}
|
||||
+222
-404
@@ -1,473 +1,291 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import type { ChatMessage, Poll } from '@shared/chat';
|
||||
import { existsSync } from 'fs';
|
||||
import type { OutgoingHttpHeaders, ServerResponse } from 'http';
|
||||
import path from 'path';
|
||||
import type { ChatMessage, ConnectionStatus, Poll, ServerEvent } from '@shared/chat';
|
||||
import { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei';
|
||||
import crypto from 'crypto';
|
||||
import { registerImageProxy } from './imageProxy';
|
||||
import { extractLiveId } from './liveId';
|
||||
|
||||
const MAX_MESSAGES = 500;
|
||||
const MAX_REGULAR_MESSAGES = 200; // Keep fewer regular messages
|
||||
const MAX_REGULAR_MESSAGES = 200;
|
||||
const MAX_SPECIAL_MESSAGES = 500;
|
||||
const RETRY_LIMIT = 30;
|
||||
const RETRY_BASE_MS = 2000;
|
||||
const RETRY_CAP_MS = 30_000;
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
const CORS_ORIGINS = ['http://localhost:3100', 'http://127.0.0.1:3100'];
|
||||
|
||||
// Simple in-memory cache for images
|
||||
const imageCache = new Map<string, { buffer: Buffer; contentType: string; timestamp: number }>();
|
||||
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
|
||||
const MAX_CACHE_SIZE = 1000; // Maximum number of cached images
|
||||
type Bus = EventEmitter<{ event: (event: ServerEvent) => void }>;
|
||||
|
||||
const isSpecial = (m: ChatMessage) => !!(m.superChat || m.membershipGift || m.membershipGiftPurchase);
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/** Drops the oldest messages of each class past its cap, in place, without allocating. */
|
||||
function trimMessages(store: ChatMessage[]): void {
|
||||
let regular = 0;
|
||||
let special = 0;
|
||||
for (const m of store) isSpecial(m) ? special++ : regular++;
|
||||
let dropRegular = Math.max(0, regular - MAX_REGULAR_MESSAGES);
|
||||
let dropSpecial = Math.max(0, special - MAX_SPECIAL_MESSAGES);
|
||||
if (!dropRegular && !dropSpecial) return;
|
||||
|
||||
let write = 0;
|
||||
for (const m of store) {
|
||||
if (isSpecial(m) ? dropSpecial > 0 : dropRegular > 0) {
|
||||
isSpecial(m) ? dropSpecial-- : dropRegular--;
|
||||
continue;
|
||||
}
|
||||
store[write++] = m;
|
||||
}
|
||||
store.length = write;
|
||||
}
|
||||
|
||||
function writeEvent(res: ServerResponse, event: ServerEvent): void {
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
export async function startBackend() {
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: 'warn', // Only show warnings and errors, not every request
|
||||
}
|
||||
});
|
||||
|
||||
// Register CORS before any routes
|
||||
await fastify.register(cors, {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'PATCH'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: false
|
||||
});
|
||||
const fastify = Fastify({ logger: { level: 'warn' } });
|
||||
await fastify.register(cors, { origin: CORS_ORIGINS, methods: ['GET', 'POST', 'DELETE', 'OPTIONS'] });
|
||||
|
||||
const store: ChatMessage[] = [];
|
||||
let currentSelection: ChatMessage | null = null;
|
||||
let currentPoll: Poll | null = null;
|
||||
const overlayEmitter = new EventEmitter<{ update: (message: ChatMessage | null) => void }>();
|
||||
const pollEmitter = new EventEmitter<{ update: (poll: Poll | null) => void }>();
|
||||
|
||||
const rawLiveId = process.env.YOUTUBE_LIVE_ID ?? '';
|
||||
const parsedLiveId = extractLiveId(rawLiveId);
|
||||
const shouldMock = !parsedLiveId;
|
||||
|
||||
const bus: Bus = new EventEmitter();
|
||||
const mockEnabled = process.env.MOCK_CHAT === '1';
|
||||
let selection: ChatMessage | null = null;
|
||||
let poll: Poll | null = null;
|
||||
let status: ConnectionStatus = { state: 'disconnected', liveId: null, title: null, error: null };
|
||||
let ingestion: IngestionContext | null = null;
|
||||
let mockInterval: NodeJS.Timeout | null = null;
|
||||
let retryTimer: NodeJS.Timeout | null = null;
|
||||
let mockTimer: NodeJS.Timeout | null = null;
|
||||
// Bumped whenever the active connection changes so a bootstrap that resolves late is discarded.
|
||||
let generation = 0;
|
||||
|
||||
if (!shouldMock) {
|
||||
try {
|
||||
console.log(`[Backend] Connecting to YouTube Live ID: ${parsedLiveId}`);
|
||||
ingestion = await bootstrapInnertube(parsedLiveId);
|
||||
console.log(`[Backend] ✓ YouTube chat connected successfully`);
|
||||
ingestion.emitter.on('message', (message) => {
|
||||
store.push(message);
|
||||
// Trim regularly to keep regular messages under control
|
||||
// This ensures we don't wait until hitting MAX_MESSAGES
|
||||
trimMessages(store);
|
||||
});
|
||||
ingestion.emitter.on('poll', (poll) => {
|
||||
currentPoll = poll;
|
||||
pollEmitter.emit('update', poll);
|
||||
});
|
||||
ingestion.emitter.on('error', (error) => {
|
||||
console.error('[Backend] Innertube ingestion error:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Backend] Failed to bootstrap Innertube; falling back to mock data:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[Backend] No YOUTUBE_LIVE_ID found, running in mock mode');
|
||||
const emit = (event: ServerEvent) => bus.emit('event', event);
|
||||
|
||||
function setStatus(patch: Partial<ConnectionStatus>) {
|
||||
status = { ...status, ...patch };
|
||||
emit({ type: 'status', status });
|
||||
}
|
||||
|
||||
if (!ingestion) {
|
||||
mockInterval = seedMockMessages(store, overlayEmitter);
|
||||
function pushMessage(message: ChatMessage) {
|
||||
store.push(message);
|
||||
trimMessages(store);
|
||||
emit({ type: 'message', message });
|
||||
}
|
||||
|
||||
function setSelection(message: ChatMessage | null) {
|
||||
selection = message;
|
||||
emit({ type: 'selection', message });
|
||||
}
|
||||
|
||||
function setPoll(next: Poll | null) {
|
||||
poll = next;
|
||||
emit({ type: 'poll', poll: next });
|
||||
}
|
||||
|
||||
function clearStore() {
|
||||
store.length = 0;
|
||||
emit({ type: 'clear' });
|
||||
if (selection) setSelection(null);
|
||||
if (poll) setPoll(null);
|
||||
}
|
||||
|
||||
function startMock() {
|
||||
if (mockTimer || !mockEnabled) return;
|
||||
console.log('[Backend] MOCK_CHAT=1, generating mock messages');
|
||||
const authors = ['Ada', 'Linus', 'Grace', 'Marge'];
|
||||
let counter = 0;
|
||||
mockTimer = setInterval(() => {
|
||||
pushMessage({
|
||||
id: `mock-${Date.now()}`,
|
||||
author: authors[counter % authors.length],
|
||||
text: `Mock message #${counter}`,
|
||||
publishedAt: new Date().toISOString()
|
||||
});
|
||||
counter += 1;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopMock() {
|
||||
if (!mockTimer) return;
|
||||
clearInterval(mockTimer);
|
||||
mockTimer = null;
|
||||
}
|
||||
|
||||
// Stops the live chat and any pending retry; the old connection's listeners are dropped.
|
||||
function detach() {
|
||||
generation += 1;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
if (ingestion) {
|
||||
ingestion.emitter.removeAllListeners();
|
||||
try {
|
||||
ingestion.liveChat?.stop?.();
|
||||
} catch (error) {
|
||||
console.error('[Backend] Error stopping live chat:', error);
|
||||
}
|
||||
ingestion = null;
|
||||
}
|
||||
}
|
||||
|
||||
function idle(patch: Partial<ConnectionStatus>) {
|
||||
setStatus({ state: 'disconnected', ...patch });
|
||||
startMock();
|
||||
}
|
||||
|
||||
function scheduleRetry(liveId: string, attempt: number, reason: string) {
|
||||
detach();
|
||||
if (attempt > RETRY_LIMIT) {
|
||||
console.error(`[Backend] Giving up on ${liveId} after ${RETRY_LIMIT} attempts: ${reason}`);
|
||||
idle({ error: `Gave up after ${RETRY_LIMIT} reconnect attempts: ${reason}` });
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_CAP_MS);
|
||||
console.warn(`[Backend] Connection lost (${reason}); retry ${attempt}/${RETRY_LIMIT} in ${delay}ms`);
|
||||
setStatus({ state: 'reconnecting', error: reason });
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
void connect(liveId, attempt);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function connect(liveId: string, attempt = 0): Promise<boolean> {
|
||||
stopMock();
|
||||
const gen = ++generation;
|
||||
if (attempt === 0) setStatus({ state: 'connecting', liveId, title: null, error: null });
|
||||
|
||||
let ctx: IngestionContext;
|
||||
try {
|
||||
ctx = await bootstrapInnertube(liveId);
|
||||
} catch (error) {
|
||||
if (gen !== generation) return false;
|
||||
const reason = errorText(error);
|
||||
console.error(`[Backend] Failed to connect to ${liveId}: ${reason}`);
|
||||
if (attempt === 0) idle({ error: reason });
|
||||
else scheduleRetry(liveId, attempt + 1, reason);
|
||||
return false;
|
||||
}
|
||||
if (gen !== generation) {
|
||||
ctx.liveChat.stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
ingestion = ctx;
|
||||
ctx.emitter.on('message', pushMessage);
|
||||
ctx.emitter.on('poll', setPoll);
|
||||
// youtubei.js retries a failed poll itself, 10 times 2s apart, and emits `end` when it gives up; only then rebuild the session.
|
||||
ctx.emitter.on('end', () => scheduleRetry(liveId, 1, 'live chat ended'));
|
||||
ctx.emitter.on('error', (error) => console.warn(`[Backend] Live chat poll error, library will retry: ${errorText(error)}`));
|
||||
setStatus({ state: 'live', liveId, title: ctx.title, error: null });
|
||||
console.log(`[Backend] Connected to ${liveId}${ctx.title ? ` (${ctx.title})` : ''}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
fastify.get('/health', async () => ({
|
||||
status: 'ok',
|
||||
connection: status,
|
||||
messages: store.length,
|
||||
selection: currentSelection?.id ?? null,
|
||||
mode: ingestion ? 'live' : 'mock',
|
||||
connected: !!ingestion,
|
||||
liveId: ingestion?.videoId ?? null
|
||||
selection: selection?.id ?? null,
|
||||
mock: mockTimer !== null
|
||||
}));
|
||||
|
||||
fastify.post<{ Body: { liveId: string } }>('/chat/connect', async (request, reply) => {
|
||||
const { liveId } = request.body ?? {};
|
||||
fastify.post<{ Body: { liveId?: string } }>('/chat/connect', async (request, reply) => {
|
||||
const liveId = extractLiveId(request.body?.liveId);
|
||||
if (!liveId) {
|
||||
reply.status(400);
|
||||
return { error: 'liveId is required' };
|
||||
}
|
||||
|
||||
const parsedLiveId = extractLiveId(liveId);
|
||||
if (!parsedLiveId) {
|
||||
reply.status(400);
|
||||
return { error: 'Invalid YouTube Live ID or URL' };
|
||||
}
|
||||
|
||||
// Stop mock messages if running
|
||||
if (mockInterval) {
|
||||
clearInterval(mockInterval);
|
||||
mockInterval = null;
|
||||
console.log('[Backend] Stopped mock messages');
|
||||
}
|
||||
|
||||
// Stop existing ingestion if any
|
||||
if (ingestion) {
|
||||
try {
|
||||
ingestion.liveChat?.stop?.();
|
||||
} catch (e) {
|
||||
console.error('[Backend] Error stopping previous connection:', e);
|
||||
}
|
||||
ingestion = null;
|
||||
}
|
||||
|
||||
// Clear messages
|
||||
store.length = 0;
|
||||
|
||||
try {
|
||||
console.log(`[Backend] Connecting to YouTube Live ID: ${parsedLiveId}`);
|
||||
ingestion = await bootstrapInnertube(parsedLiveId);
|
||||
console.log(`[Backend] ✓ YouTube chat connected successfully`);
|
||||
|
||||
ingestion.emitter.on('message', (message) => {
|
||||
store.push(message);
|
||||
// Trim regularly to keep regular messages under control
|
||||
// This ensures we don't wait until hitting MAX_MESSAGES
|
||||
trimMessages(store);
|
||||
});
|
||||
|
||||
ingestion.emitter.on('poll', (poll) => {
|
||||
currentPoll = poll;
|
||||
pollEmitter.emit('update', poll);
|
||||
});
|
||||
|
||||
ingestion.emitter.on('error', (error) => {
|
||||
console.error('[Backend] Innertube ingestion error:', error);
|
||||
});
|
||||
|
||||
return { ok: true, liveId: parsedLiveId };
|
||||
} catch (error) {
|
||||
console.error('[Backend] Failed to connect:', error);
|
||||
detach();
|
||||
clearStore();
|
||||
if (!(await connect(liveId))) {
|
||||
reply.status(500);
|
||||
return { error: 'Failed to connect to YouTube Live chat' };
|
||||
return { error: status.error ?? 'Failed to connect to YouTube Live chat' };
|
||||
}
|
||||
return { ok: true, liveId, title: status.title ?? null };
|
||||
});
|
||||
|
||||
fastify.post('/chat/disconnect', async () => {
|
||||
if (ingestion) {
|
||||
try {
|
||||
ingestion.liveChat?.stop?.();
|
||||
console.log('[Backend] Disconnected from YouTube chat');
|
||||
} catch (e) {
|
||||
console.error('[Backend] Error disconnecting:', e);
|
||||
}
|
||||
ingestion = null;
|
||||
}
|
||||
|
||||
// Start mock messages again
|
||||
if (!mockInterval) {
|
||||
mockInterval = seedMockMessages(store, overlayEmitter);
|
||||
console.log('[Backend] Started mock messages');
|
||||
}
|
||||
|
||||
store.length = 0;
|
||||
currentSelection = null;
|
||||
overlayEmitter.emit('update', null);
|
||||
detach();
|
||||
clearStore();
|
||||
idle({ liveId: null, title: null, error: null });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.get('/chat/messages', async () => ({
|
||||
messages: store
|
||||
}));
|
||||
|
||||
fastify.get('/poll/current', async () => ({
|
||||
poll: currentPoll
|
||||
}));
|
||||
fastify.get('/chat/messages', async () => ({ messages: store }));
|
||||
|
||||
fastify.post<{ Body: { id?: string } }>('/overlay/selection', async (request, reply) => {
|
||||
const { id } = request.body ?? {};
|
||||
const id = request.body?.id;
|
||||
if (!id) {
|
||||
reply.status(400);
|
||||
return { error: 'id is required' };
|
||||
}
|
||||
|
||||
const message = store.find((item) => item.id === id);
|
||||
if (!message) {
|
||||
reply.status(404);
|
||||
return { error: 'message not found' };
|
||||
}
|
||||
|
||||
currentSelection = message;
|
||||
overlayEmitter.emit('update', currentSelection);
|
||||
|
||||
setSelection(message);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.delete('/overlay/selection', async () => {
|
||||
currentSelection = null;
|
||||
overlayEmitter.emit('update', null);
|
||||
setSelection(null);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
fastify.get('/poll/stream', async (request, reply) => {
|
||||
fastify.get('/events', (request, reply) => {
|
||||
reply.hijack();
|
||||
|
||||
const res = reply.raw;
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.writeHead(200);
|
||||
res.write(': connected\n\n');
|
||||
|
||||
const send = (poll: Poll | null) => {
|
||||
res.write(`event: poll\ndata: ${JSON.stringify({ poll })}\n\n`);
|
||||
};
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write('event: heartbeat\ndata: {}\n\n');
|
||||
}, 15000);
|
||||
|
||||
pollEmitter.on('update', send);
|
||||
|
||||
if (currentPoll) {
|
||||
send(currentPoll);
|
||||
}
|
||||
// Headers set by hooks (CORS) are not flushed on a hijacked reply, so copy them.
|
||||
res.writeHead(200, {
|
||||
...(reply.getHeaders() as OutgoingHttpHeaders),
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
});
|
||||
writeEvent(res, { type: 'init', status, messages: store, selection, poll });
|
||||
|
||||
const forward = (event: ServerEvent) => writeEvent(res, event);
|
||||
bus.on('event', forward);
|
||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS);
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
pollEmitter.off('update', send);
|
||||
bus.off('event', forward);
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get('/overlay/stream', async (request, reply) => {
|
||||
reply.hijack();
|
||||
registerImageProxy(fastify);
|
||||
|
||||
const res = reply.raw;
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.writeHead(200);
|
||||
res.write(': connected\n\n');
|
||||
|
||||
const send = (message: ChatMessage | null) => {
|
||||
res.write(`event: selection\ndata: ${JSON.stringify({ message })}\n\n`);
|
||||
};
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write('event: heartbeat\ndata: {}\n\n');
|
||||
}, 15000);
|
||||
|
||||
overlayEmitter.on('update', send);
|
||||
|
||||
if (currentSelection) {
|
||||
send(currentSelection);
|
||||
}
|
||||
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
overlayEmitter.off('update', send);
|
||||
});
|
||||
});
|
||||
|
||||
// Image proxy endpoint to avoid YouTube CDN rate limits
|
||||
fastify.get<{ Querystring: { url: string } }>('/proxy/image', async (request, reply) => {
|
||||
const { url } = request.query;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
reply.status(400);
|
||||
return { error: 'url parameter is required' };
|
||||
}
|
||||
|
||||
// Only allow YouTube CDN and Google User Content domains
|
||||
const allowedDomains = [
|
||||
'yt3.ggpht.com',
|
||||
'yt4.ggpht.com',
|
||||
'i.ytimg.com',
|
||||
'lh3.googleusercontent.com' // For super stickers
|
||||
];
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (!allowedDomains.includes(urlObj.hostname)) {
|
||||
reply.status(403);
|
||||
return { error: 'Only YouTube CDN and Google User Content URLs are allowed' };
|
||||
}
|
||||
} catch (error) {
|
||||
reply.status(400);
|
||||
return { error: 'Invalid URL' };
|
||||
}
|
||||
|
||||
// Create cache key from URL
|
||||
const cacheKey = crypto.createHash('md5').update(url).digest('hex');
|
||||
|
||||
// Check cache
|
||||
const cached = imageCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||||
reply.header('Content-Type', cached.contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=86400'); // 24 hours
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
return reply.send(cached.buffer);
|
||||
}
|
||||
|
||||
// Fetch from YouTube
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Referer': 'https://www.youtube.com/',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
console.warn('[Backend] Rate limited by YouTube CDN for:', url);
|
||||
// Return from cache even if expired, or return error
|
||||
if (cached) {
|
||||
reply.header('Content-Type', cached.contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
return reply.send(cached.buffer);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
|
||||
// Cache the image
|
||||
imageCache.set(cacheKey, {
|
||||
buffer,
|
||||
contentType,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Cleanup old cache entries if we exceed max size
|
||||
if (imageCache.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(imageCache.entries());
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
const toDelete = entries.slice(0, Math.floor(MAX_CACHE_SIZE * 0.2)); // Remove oldest 20%
|
||||
toDelete.forEach(([key]) => imageCache.delete(key));
|
||||
}
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
return reply.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('[Backend] Failed to proxy image:', error);
|
||||
|
||||
// Try to return stale cache if available
|
||||
if (cached) {
|
||||
reply.header('Content-Type', cached.contentType);
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
return reply.send(cached.buffer);
|
||||
}
|
||||
|
||||
reply.status(500);
|
||||
return { error: 'Failed to fetch image' };
|
||||
}
|
||||
});
|
||||
// Production serves the exported Next.js client; in dev the client runs on its own port.
|
||||
const clientDir = path.resolve(process.cwd(), 'client/out');
|
||||
if (existsSync(clientDir)) {
|
||||
await fastify.register(fastifyStatic, { root: clientDir, index: ['index.html'], redirect: true });
|
||||
fastify.get('/', (_request, reply) => reply.redirect('/dashboard/'));
|
||||
console.log(`[Backend] Serving client from ${clientDir}`);
|
||||
} else {
|
||||
console.log('[Backend] client/out not found, serving API only');
|
||||
}
|
||||
|
||||
const host = process.env.HOST ?? '127.0.0.1';
|
||||
const port = Number(process.env.PORT ?? 4100);
|
||||
await fastify.listen({ port, host });
|
||||
console.log(`[Backend] Listening on http://${host}:${port}`);
|
||||
|
||||
await fastify.listen({ port, host: '0.0.0.0' });
|
||||
|
||||
console.log(`[Backend] Server listening on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
function extractLiveId(input: string): string {
|
||||
if (!input) return '';
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (/^[a-zA-Z0-9_-]{10,}$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.searchParams.has('v')) {
|
||||
return url.searchParams.get('v') ?? '';
|
||||
}
|
||||
const pathname = url.pathname.split('/').filter(Boolean).pop();
|
||||
return pathname ?? '';
|
||||
} catch (error) {
|
||||
console.warn('Invalid YOUTUBE_LIVE_ID provided', error);
|
||||
return '';
|
||||
const envLiveId = extractLiveId(process.env.YOUTUBE_LIVE_ID);
|
||||
if (envLiveId) {
|
||||
// Attempt 1 rather than 0 so a failed startup connect goes through the retry schedule instead of giving up.
|
||||
void connect(envLiveId, 1);
|
||||
} else {
|
||||
if (process.env.YOUTUBE_LIVE_ID) console.warn('[Backend] Ignoring invalid YOUTUBE_LIVE_ID');
|
||||
startMock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligently trim messages while preserving superchats and memberships
|
||||
* Regular messages are limited to MAX_REGULAR_MESSAGES
|
||||
* Superchats and memberships are preserved for the entire session
|
||||
*/
|
||||
function trimMessages(store: ChatMessage[]): void {
|
||||
// Count messages by type
|
||||
let regularCount = 0;
|
||||
const specialIndices: number[] = [];
|
||||
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const message = store[i];
|
||||
const isSpecial = message.superChat || message.membershipGift ||
|
||||
message.membershipGiftPurchase || message.isMember;
|
||||
if (isSpecial) {
|
||||
specialIndices.push(i);
|
||||
} else {
|
||||
regularCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Only trim if we have too many regular messages
|
||||
if (regularCount > MAX_REGULAR_MESSAGES) {
|
||||
const toRemove = regularCount - MAX_REGULAR_MESSAGES;
|
||||
const specialSet = new Set(specialIndices);
|
||||
|
||||
// Remove oldest regular messages (keep special messages)
|
||||
let removed = 0;
|
||||
const newStore: ChatMessage[] = [];
|
||||
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const isSpecial = specialSet.has(i);
|
||||
|
||||
if (isSpecial) {
|
||||
// Always keep special messages
|
||||
newStore.push(store[i]);
|
||||
} else {
|
||||
// Keep regular messages if we haven't removed enough yet
|
||||
if (removed < toRemove) {
|
||||
removed++;
|
||||
// Skip this message (delete it)
|
||||
} else {
|
||||
newStore.push(store[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace store contents
|
||||
store.length = 0;
|
||||
store.push(...newStore);
|
||||
}
|
||||
}
|
||||
|
||||
function seedMockMessages(
|
||||
store: ChatMessage[],
|
||||
overlayEmitter: EventEmitter<{ update: (message: ChatMessage | null) => void }>
|
||||
): NodeJS.Timeout {
|
||||
let counter = 0;
|
||||
const authors = ['Ada', 'Linus', 'Grace', 'Marge'];
|
||||
return setInterval(() => {
|
||||
const message: ChatMessage = {
|
||||
id: `mock-${Date.now()}`,
|
||||
author: authors[counter % authors.length],
|
||||
text: `Mock message #${counter}`,
|
||||
publishedAt: new Date().toISOString()
|
||||
};
|
||||
store.push(message);
|
||||
// Trim regularly to keep regular messages under control
|
||||
trimMessages(store);
|
||||
if (counter % 5 === 0) {
|
||||
overlayEmitter.emit('update', message);
|
||||
}
|
||||
counter += 1;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Only run if this is the main module
|
||||
if (require.main === module) {
|
||||
startBackend().catch((error) => {
|
||||
console.error('Failed to start backend', error);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveRunUrl } from './youtubei';
|
||||
|
||||
test('empty input', () => {
|
||||
assert.equal(resolveRunUrl(undefined), undefined);
|
||||
assert.equal(resolveRunUrl(''), undefined);
|
||||
});
|
||||
|
||||
test('plain https URL is returned unchanged', () => {
|
||||
assert.equal(resolveRunUrl('https://example.com/path?a=1&b=2'), 'https://example.com/path?a=1&b=2');
|
||||
assert.equal(resolveRunUrl('http://example.com'), 'http://example.com');
|
||||
});
|
||||
|
||||
test('site-relative path becomes an absolute youtube.com URL', () => {
|
||||
assert.equal(resolveRunUrl('/watch?v=dQw4w9WgXcQ'), 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
||||
assert.equal(resolveRunUrl('/@channel'), 'https://www.youtube.com/@channel');
|
||||
});
|
||||
|
||||
test('redirect wrapper resolves to the encoded q parameter', () => {
|
||||
const wrapped =
|
||||
'https://www.youtube.com/redirect?event=live_chat&redir_token=TOKEN&q=https%3A%2F%2Fexample.com%2Fp%3Fa%3D1%26b%3D2&html_redirect=1';
|
||||
assert.equal(resolveRunUrl(wrapped), 'https://example.com/p?a=1&b=2');
|
||||
assert.equal(resolveRunUrl('/redirect?q=https%3A%2F%2Fexample.com'), 'https://example.com');
|
||||
assert.equal(resolveRunUrl('https://www.youtube.com/redirect?event=x'), 'https://www.youtube.com/redirect?event=x');
|
||||
});
|
||||
|
||||
test('unparseable input', () => {
|
||||
assert.equal(resolveRunUrl('not a url'), undefined);
|
||||
});
|
||||
+164
-274
@@ -1,75 +1,121 @@
|
||||
import type { ChatMessage, Badge, SuperChatInfo, Poll } from '@shared/chat';
|
||||
import type { ChatMessage, Badge, MessageRun, SuperChatInfo, Poll } from '@shared/chat';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
import Innertube, { UniversalCache } from 'youtubei.js';
|
||||
import Innertube from 'youtubei.js';
|
||||
|
||||
export type IngestionContext = {
|
||||
client: Innertube;
|
||||
liveChat: any;
|
||||
videoId: string;
|
||||
title: string | null;
|
||||
emitter: ChatEventEmitter;
|
||||
};
|
||||
|
||||
export type ContinuationState = {
|
||||
messages: ChatMessage[];
|
||||
nextToken: string | null;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type ChatEventEmitter = EventEmitter<{
|
||||
message: (message: ChatMessage) => void;
|
||||
poll: (poll: Poll | null) => void;
|
||||
error: (error: unknown) => void;
|
||||
end: () => void;
|
||||
}>;
|
||||
|
||||
const defaultTimeout = 1500;
|
||||
const SEEN_IDS_CAP = 5000;
|
||||
|
||||
// Track message IDs to ensure uniqueness
|
||||
// YouTube reuses message ids in busy chats; a per-id counter keeps ours unique.
|
||||
const seenIds = new Map<string, number>();
|
||||
|
||||
function generateUniqueId(baseId: string): string {
|
||||
if (seenIds.size > SEEN_IDS_CAP) seenIds.clear();
|
||||
const count = seenIds.get(baseId) ?? 0;
|
||||
seenIds.set(baseId, count + 1);
|
||||
|
||||
// If this is the first occurrence of this ID, return it as-is
|
||||
if (count === 0) {
|
||||
return baseId;
|
||||
}
|
||||
|
||||
// Otherwise, append a counter suffix to ensure uniqueness
|
||||
return `${baseId}#${count}`;
|
||||
return count === 0 ? baseId : `${baseId}#${count}`;
|
||||
}
|
||||
|
||||
function resolveMessageRuns(item: any) {
|
||||
const runs: { text?: string; emojiUrl?: string; emojiAlt?: string }[] = [];
|
||||
/**
|
||||
* Turns the raw navigation URL of a text run into the absolute destination.
|
||||
* YouTube wraps external links in /redirect?q=<url> and uses site-relative paths for its own.
|
||||
*/
|
||||
export function resolveRunUrl(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const absolute = raw.startsWith('/') ? `https://www.youtube.com${raw}` : raw;
|
||||
try {
|
||||
const url = new URL(absolute);
|
||||
if (url.hostname.endsWith('youtube.com') && url.pathname === '/redirect') {
|
||||
return url.searchParams.get('q') ?? absolute;
|
||||
}
|
||||
return absolute;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the run endpoint youtubei.js exposes on TextRun. Verified against the library types, not yet against a live chat carrying a long link.
|
||||
function runUrl(run: any): string | undefined {
|
||||
return resolveRunUrl(
|
||||
run?.endpoint?.payload?.url ??
|
||||
run?.endpoint?.metadata?.url ??
|
||||
run?.navigationEndpoint?.urlEndpoint?.url ??
|
||||
run?.navigationEndpoint?.commandMetadata?.webCommandMetadata?.url
|
||||
);
|
||||
}
|
||||
|
||||
// The visible text of a link run may be shortened; the endpoint keeps the full URL.
|
||||
function isTruncated(text: string, url: string): boolean {
|
||||
return text.endsWith('...') || text.endsWith('…') || text.length < url.length;
|
||||
}
|
||||
|
||||
function resolveMessageRuns(item: any): MessageRun[] {
|
||||
const runs: MessageRun[] = [];
|
||||
const rawRuns = item?.message?.runs;
|
||||
if (Array.isArray(rawRuns)) {
|
||||
for (const run of rawRuns) {
|
||||
if (run?.emoji) {
|
||||
const url = run.emoji?.image?.[0]?.url;
|
||||
runs.push({
|
||||
emojiUrl: url,
|
||||
emojiAlt: run.emoji?.shortcuts?.[0] || run.emoji?.emoji_id || ''
|
||||
});
|
||||
} else if (run?.text) {
|
||||
runs.push({ text: String(run.text) });
|
||||
}
|
||||
if (!Array.isArray(rawRuns)) return runs;
|
||||
for (const run of rawRuns) {
|
||||
if (run?.emoji) {
|
||||
runs.push({
|
||||
emojiUrl: run.emoji?.image?.[0]?.url,
|
||||
emojiAlt: run.emoji?.shortcuts?.[0] || run.emoji?.emoji_id || ''
|
||||
});
|
||||
} else if (run?.text) {
|
||||
const url = runUrl(run);
|
||||
runs.push(url ? { text: String(run.text), url } : { text: String(run.text) });
|
||||
}
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function resolveMessageText(item: any): string {
|
||||
const message = item?.message;
|
||||
if (!message) return '';
|
||||
if (typeof message === 'string') return message;
|
||||
|
||||
if (Array.isArray(message.runs)) {
|
||||
return message.runs
|
||||
.map((run: any) => {
|
||||
if (run?.emoji) {
|
||||
return (run.emoji.is_custom && run.emoji.shortcuts?.[0]) || run.emoji.emoji_id || run.text || '';
|
||||
}
|
||||
const text = String(run?.text ?? '');
|
||||
const url = runUrl(run);
|
||||
return url && isTruncated(text, url) ? url : text;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
return typeof message.toString === 'function' ? message.toString() : '';
|
||||
}
|
||||
|
||||
export async function bootstrapInnertube(videoId: string): Promise<IngestionContext> {
|
||||
if (!videoId) {
|
||||
throw new Error('YOUTUBE_LIVE_ID is required to bootstrap Innertube');
|
||||
}
|
||||
seenIds.clear();
|
||||
|
||||
console.log('[Ingestion] Creating Innertube client...');
|
||||
const client = await Innertube.create({
|
||||
cache: new UniversalCache(false)
|
||||
});
|
||||
// No cache: even UniversalCache(false) writes session data to os.tmpdir(), and a session captured during a
|
||||
// network hiccup came back poisoned on every reconnect (YouTube answered 400 "unusual traffic" until it was deleted).
|
||||
// The player is skipped because chat never needs stream formats.
|
||||
const client = await Innertube.create({ retrieve_player: false });
|
||||
|
||||
console.log('[Ingestion] Fetching video info...');
|
||||
const info = await client.getInfo(videoId);
|
||||
const title = info.basic_info?.title ?? null;
|
||||
|
||||
console.log('[Ingestion] Getting live chat...');
|
||||
const liveChat = info.getLiveChat();
|
||||
@@ -81,7 +127,6 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
|
||||
const emitter: ChatEventEmitter = new EventEmitter();
|
||||
|
||||
liveChat.on('chat-update', (action: any) => {
|
||||
// Handle poll updates (just detect active/closed state)
|
||||
if (action?.type === 'UpdateLiveChatPollAction') {
|
||||
const pollId = action?.poll_to_update?.live_chat_poll_id;
|
||||
if (pollId) {
|
||||
@@ -90,7 +135,6 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle poll closing
|
||||
if (action?.type === 'CloseLiveChatActionPanelAction' || action?.type === 'RemoveBannerForLiveChatCommand') {
|
||||
emitter.emit('poll', null);
|
||||
return;
|
||||
@@ -104,85 +148,19 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
|
||||
|
||||
liveChat.on('error', (err: unknown) => {
|
||||
const msg = (err as any)?.message || String(err);
|
||||
// Ignore known non-fatal parser drift issues that YouTube.js auto-generates
|
||||
if (msg && (
|
||||
msg.includes('LiveChatReportModerationStateCommand not found') ||
|
||||
msg.includes('CloseLiveChatActionPanelAction not found')
|
||||
)) {
|
||||
return; // ignore noisy parser drift that YouTube.js JITs around
|
||||
}
|
||||
// Unknown renderer types are parser drift that youtubei.js generates stubs for; not a connection problem.
|
||||
if (/\w+(Command|Action) not found/.test(msg)) return;
|
||||
console.error('[Ingestion] Live chat error:', err);
|
||||
emitter.emit('error', err);
|
||||
});
|
||||
|
||||
liveChat.on('end', () => emitter.emit('end'));
|
||||
|
||||
console.log('[Ingestion] Starting live chat listener...');
|
||||
liveChat.start();
|
||||
console.log('[Ingestion] Live chat listener started');
|
||||
|
||||
return {
|
||||
client,
|
||||
liveChat,
|
||||
videoId,
|
||||
emitter
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchChatBatch(
|
||||
ctx: IngestionContext,
|
||||
options?: { windowMs?: number }
|
||||
): Promise<ContinuationState> {
|
||||
const windowMs = options?.windowMs ?? defaultTimeout;
|
||||
const collected: ChatMessage[] = [];
|
||||
|
||||
const listener = (message: ChatMessage) => {
|
||||
collected.push(message);
|
||||
};
|
||||
|
||||
ctx.emitter.on('message', listener);
|
||||
|
||||
await delay(windowMs);
|
||||
|
||||
ctx.emitter.off('message', listener);
|
||||
|
||||
const timeoutMs = ctx.liveChat?.continuation?.timeout_ms;
|
||||
const validTimeout = typeof timeoutMs === 'number' && !isNaN(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: defaultTimeout;
|
||||
|
||||
return {
|
||||
messages: collected,
|
||||
nextToken: ctx.liveChat?.continuation?.token ?? null,
|
||||
timeoutMs: validTimeout
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMessageText(item: any): string {
|
||||
if (!item?.message) return '';
|
||||
|
||||
if (typeof item.message === 'string') {
|
||||
return item.message;
|
||||
}
|
||||
|
||||
if (typeof item.message?.toString === 'function') {
|
||||
return item.message.toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(item.message?.runs)) {
|
||||
return item.message.runs.map((run: any) => {
|
||||
// Handle emoji objects - for custom emojis, use the shortcut text
|
||||
if (run.emoji) {
|
||||
// For custom emojis, use the first shortcut (e.g., ":_heçkır:")
|
||||
if (run.emoji.is_custom && run.emoji.shortcuts?.[0]) {
|
||||
return run.emoji.shortcuts[0];
|
||||
}
|
||||
// For standard emojis, use emoji_id (e.g., "❤")
|
||||
return run.emoji.emoji_id || run.text || '';
|
||||
}
|
||||
return run.text ?? '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
return { client, liveChat, videoId, title, emitter };
|
||||
}
|
||||
|
||||
function resolveTimestamp(timestamp: number | string | undefined): string {
|
||||
@@ -193,68 +171,32 @@ function resolveTimestamp(timestamp: number | string | undefined): string {
|
||||
const numeric = typeof timestamp === 'string' ? Number(timestamp) : timestamp;
|
||||
|
||||
if (Number.isFinite(numeric)) {
|
||||
// YouTube timestamps are in microseconds (16 digits) or milliseconds (13 digits)
|
||||
// If it's microseconds (>= 1e15), divide by 1000 to get milliseconds
|
||||
// If it's milliseconds (>= 1e12), use as is
|
||||
let millis: number;
|
||||
if (numeric >= 1e15) {
|
||||
// Microseconds - convert to milliseconds
|
||||
millis = numeric / 1000;
|
||||
} else if (numeric >= 1e12) {
|
||||
// Already milliseconds
|
||||
millis = numeric;
|
||||
} else {
|
||||
// Fallback for smaller numbers
|
||||
millis = numeric;
|
||||
}
|
||||
|
||||
// YouTube sends microseconds (16 digits) or milliseconds (13 digits).
|
||||
const millis = numeric >= 1e15 ? numeric / 1000 : numeric;
|
||||
return new Date(millis).toISOString();
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function extractBadges(item: any): Badge[] {
|
||||
function extractBadges(list: any): Badge[] {
|
||||
const badges: Badge[] = [];
|
||||
if (!Array.isArray(list)) return badges;
|
||||
|
||||
if (!item.author?.badges) return badges;
|
||||
|
||||
for (const badge of item.author.badges) {
|
||||
const label = badge.tooltip ?? badge.label ?? '';
|
||||
for (const badge of list) {
|
||||
const label: string = badge.tooltip ?? badge.label ?? '';
|
||||
const iconType: string = badge.icon_type ?? '';
|
||||
const imageUrl = badge.custom_thumbnail?.[0]?.url;
|
||||
const lower = label.toLowerCase();
|
||||
|
||||
if (label.toLowerCase().includes('moderator')) {
|
||||
if (iconType === 'MODERATOR' || lower.includes('moderator')) {
|
||||
badges.push({ type: 'moderator', label, imageUrl });
|
||||
} else if (label.toLowerCase().includes('member')) {
|
||||
} else if (lower.includes('member')) {
|
||||
badges.push({ type: 'member', label, imageUrl });
|
||||
} else if (label.toLowerCase().includes('verified')) {
|
||||
badges.push({ type: 'verified', label, imageUrl });
|
||||
} else if (label) {
|
||||
badges.push({ type: 'custom', label, imageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
function extractBadgesFromHeader(header: any): Badge[] {
|
||||
const badges: Badge[] = [];
|
||||
|
||||
if (!header?.author_badges) return badges;
|
||||
|
||||
for (const badge of header.author_badges) {
|
||||
const label = badge.tooltip ?? '';
|
||||
const iconType = badge.icon_type ?? '';
|
||||
const imageUrl = badge.custom_thumbnail?.[0]?.url;
|
||||
|
||||
if (label.toLowerCase().includes('moderator') || iconType === 'MODERATOR') {
|
||||
badges.push({ type: 'moderator', label, imageUrl });
|
||||
} else if (label.toLowerCase().includes('member')) {
|
||||
badges.push({ type: 'member', label, imageUrl });
|
||||
} else if (label.toLowerCase().includes('verified') || iconType === 'VERIFIED') {
|
||||
} else if (iconType === 'VERIFIED' || lower.includes('verified')) {
|
||||
badges.push({ type: 'verified', label, imageUrl });
|
||||
} else if (iconType === 'OWNER') {
|
||||
badges.push({ type: 'custom', label: label || 'Owner', imageUrl });
|
||||
badges.push({ type: 'owner', label: label || 'Owner', imageUrl });
|
||||
} else if (label) {
|
||||
badges.push({ type: 'custom', label, imageUrl });
|
||||
}
|
||||
@@ -264,13 +206,11 @@ function extractBadgesFromHeader(header: any): Badge[] {
|
||||
}
|
||||
|
||||
function extractLeaderboardRank(item: any): number | undefined {
|
||||
// Check for before_content_buttons array (where leaderboard badge appears)
|
||||
if (!Array.isArray(item.before_content_buttons)) return undefined;
|
||||
|
||||
for (const button of item.before_content_buttons) {
|
||||
// Look for CROWN icon (leaderboard indicator)
|
||||
// The CROWN button carries the rank as "#3".
|
||||
if (button.icon_name === 'CROWN' && button.title) {
|
||||
// Title is like "#3", "#1", etc.
|
||||
const match = String(button.title).match(/#(\d+)/);
|
||||
if (match && match[1]) {
|
||||
return parseInt(match[1], 10);
|
||||
@@ -294,8 +234,8 @@ function extractSuperChatInfo(item: any): SuperChatInfo | undefined {
|
||||
|
||||
let amountText = '';
|
||||
const candidates = [
|
||||
item.purchase_amount, // Correct property name from youtubei.js docs
|
||||
item.purchase_amount_text, // Fallback
|
||||
item.purchase_amount,
|
||||
item.purchase_amount_text,
|
||||
item.purchaseAmountText,
|
||||
item.header?.purchase_amount,
|
||||
item.header?.purchase_amount_text,
|
||||
@@ -325,25 +265,21 @@ function extractSuperChatInfo(item: any): SuperChatInfo | undefined {
|
||||
let currency = '';
|
||||
|
||||
if (amountText) {
|
||||
// Regex to capture currency symbol/code and amount
|
||||
// Handles: $5.00, €5,00, 5,00 €, 5.00 USD, TRY5.00, TRY 55, etc.
|
||||
// Handles $5.00, 5,00 EUR, TRY 55 and similar: symbol or code on either side of the number.
|
||||
const match = amountText.match(/([\$\€\£\¥\₹\₺]|[A-Z]{2,3})?\s*([\d,\.]+)\s*([\$\€\£\¥\₹\₺]|[A-Z]{2,3})?/);
|
||||
if (match) {
|
||||
// Prefer currency symbol/code before the number, fallback to after
|
||||
currency = match[1] || match[3] || '';
|
||||
amount = match[2];
|
||||
} else {
|
||||
// Fallback: use the whole text if no pattern matches
|
||||
amount = amountText;
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have an amount, use a default
|
||||
if (!amount) {
|
||||
amount = 'Super Chat';
|
||||
}
|
||||
|
||||
let rawColor = item.body_background_color ?? item.bodyBackgroundColor ?? item.headerBackgroundColor;
|
||||
const rawColor = item.body_background_color ?? item.bodyBackgroundColor ?? item.headerBackgroundColor;
|
||||
let color = '#1e3a8a';
|
||||
if (rawColor != null) {
|
||||
if (typeof rawColor === 'number') {
|
||||
@@ -355,16 +291,13 @@ function extractSuperChatInfo(item: any): SuperChatInfo | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract super sticker image URL if present
|
||||
let stickerUrl: string | undefined;
|
||||
let stickerAlt: string | undefined;
|
||||
|
||||
if (Array.isArray(item.sticker) && item.sticker.length > 0) {
|
||||
// Prefer larger image (first in array is usually largest)
|
||||
const stickerThumb = item.sticker[0];
|
||||
if (stickerThumb?.url) {
|
||||
// URLs from YouTube might be protocol-relative (//domain.com)
|
||||
// Convert to absolute HTTPS URL
|
||||
// Sticker URLs may be protocol-relative.
|
||||
let url = String(stickerThumb.url);
|
||||
if (url.startsWith('//')) {
|
||||
url = 'https:' + url;
|
||||
@@ -374,7 +307,6 @@ function extractSuperChatInfo(item: any): SuperChatInfo | undefined {
|
||||
stickerUrl = url;
|
||||
}
|
||||
|
||||
// Extract accessibility label for alt text
|
||||
if (item.sticker_accessibility_label) {
|
||||
stickerAlt = String(item.sticker_accessibility_label);
|
||||
}
|
||||
@@ -397,9 +329,9 @@ function normalizeAction(action: any): ChatMessage | null {
|
||||
const item = action.item;
|
||||
if (!item) return null;
|
||||
|
||||
// Normalize various live chat events
|
||||
const itemType = String(item.type || '').trim();
|
||||
const messageText = resolveMessageText(item).toLowerCase();
|
||||
const resolvedText = resolveMessageText(item);
|
||||
const messageText = resolvedText.toLowerCase();
|
||||
|
||||
const isText = itemType === 'LiveChatTextMessage';
|
||||
const isPaid = !!extractSuperChatInfo(item);
|
||||
@@ -407,118 +339,76 @@ function normalizeAction(action: any): ChatMessage | null {
|
||||
const isGiftPurchase = itemType === 'LiveChatSponsorshipsGiftPurchaseAnnouncement';
|
||||
const isGiftReceived = itemType === 'LiveChatSponsorshipsGiftRedemptionAnnouncement';
|
||||
|
||||
// Check if the message text indicates it's a gift recipient message
|
||||
const isGiftRecipientMessage =
|
||||
messageText.includes('received a gift membership') ||
|
||||
messageText.includes('received a membership gift') ||
|
||||
messageText.includes('received a gift') ||
|
||||
/received\s+a\s+.*membership.*by/i.test(messageText);
|
||||
|
||||
// Ignore gift received messages - we only care about the purchaser
|
||||
// Only the purchaser of a gift is shown, not each recipient.
|
||||
if (isGiftReceived || isGiftRecipientMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isText || isPaid || isMembership || isGiftPurchase) {
|
||||
// For gift purchases, author info is in header
|
||||
const authorSource = isGiftPurchase ? item.header : item;
|
||||
const badges = isGiftPurchase ? extractBadgesFromHeader(item.header) : extractBadges(item);
|
||||
const isModerator = badges.some(b => b.type === 'moderator');
|
||||
const isMember = badges.some(b => b.type === 'member');
|
||||
const isVerified = badges.some(b => b.type === 'verified');
|
||||
|
||||
// Extract membership level for new members, upgrades, and milestones
|
||||
let membershipLevel: string | undefined;
|
||||
if (isMembership) {
|
||||
const subtext: string = item.header_subtext?.text || '';
|
||||
const primaryText: string = item.header_primary_text?.text || '';
|
||||
|
||||
// Try to capture level name from common phrases
|
||||
// e.g., "Welcome to User!" => "User"
|
||||
// e.g., "Upgraded membership to Superuser!" => "Superuser"
|
||||
let levelFromSub: string | undefined;
|
||||
const welcomeMatch = subtext.match(/Welcome to\s+(.+?)!/i);
|
||||
const upgradeMatch = subtext.match(/Upgraded membership to\s+(.+?)!/i);
|
||||
if (welcomeMatch?.[1]) {
|
||||
levelFromSub = welcomeMatch[1].trim();
|
||||
} else if (upgradeMatch?.[1]) {
|
||||
levelFromSub = upgradeMatch[1].trim();
|
||||
}
|
||||
|
||||
membershipLevel = levelFromSub || primaryText || 'New member';
|
||||
} else if (isGiftPurchase) {
|
||||
// For gift purchases, we don't need membership level
|
||||
membershipLevel = undefined;
|
||||
} else if (isPaid) {
|
||||
membershipLevel = undefined;
|
||||
}
|
||||
|
||||
// Extract gift count for gift purchases
|
||||
let giftCount: number | undefined;
|
||||
if (isGiftPurchase) {
|
||||
const primaryText = item.header?.primary_text?.text || '';
|
||||
// Extract number from "Sent 1 Yusuf İpek gift memberships"
|
||||
const countMatch = primaryText.match(/sent\s+(\d+)\s+/i);
|
||||
if (countMatch) {
|
||||
giftCount = parseInt(countMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract channel ID - for gifts it's in author_external_channel_id
|
||||
const authorChannelId = isGiftPurchase
|
||||
? item.author_external_channel_id
|
||||
: item.author?.id;
|
||||
|
||||
// Extract author name and photo
|
||||
const authorName = isGiftPurchase
|
||||
? (item.header?.author_name?.text || 'Unknown')
|
||||
: String(item.author?.name ?? 'Unknown');
|
||||
|
||||
const authorPhoto = isGiftPurchase
|
||||
? item.header?.author_photo?.[0]?.url
|
||||
: item.author?.thumbnails?.[0]?.url;
|
||||
|
||||
// Build text with fallback: if no user message, show header subtext/primary for membership events
|
||||
const resolvedText = resolveMessageText(item);
|
||||
const membershipFallbackText = isMembership
|
||||
? (item.header_subtext?.text || item.header_primary_text?.text || '')
|
||||
: '';
|
||||
|
||||
// Use timestamp_usec (microseconds) as it's more accurate than timestamp (milliseconds)
|
||||
const timestampToUse = item.timestamp_usec ?? item.timestamp;
|
||||
|
||||
// Extract leaderboard rank if present
|
||||
const leaderboardRank = extractLeaderboardRank(item);
|
||||
|
||||
// Generate a unique ID - YouTube message IDs might not be unique in high-frequency chats
|
||||
// so we track seen IDs and append a counter if needed
|
||||
const baseId = String(item.id ?? item.timestamp_usec ?? Date.now());
|
||||
const uniqueId = generateUniqueId(baseId);
|
||||
|
||||
return {
|
||||
id: uniqueId,
|
||||
author: authorName,
|
||||
authorPhoto,
|
||||
authorChannelId: authorChannelId ? String(authorChannelId) : undefined,
|
||||
text: resolvedText || membershipFallbackText,
|
||||
runs: (() => { const r = resolveMessageRuns(item); return r.length ? r : undefined; })(),
|
||||
publishedAt: resolveTimestamp(timestampToUse),
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
isModerator,
|
||||
isMember,
|
||||
isVerified,
|
||||
superChat: extractSuperChatInfo(item),
|
||||
membershipGift: isMembership,
|
||||
membershipGiftPurchase: isGiftPurchase,
|
||||
membershipLevel,
|
||||
giftCount,
|
||||
leaderboardRank
|
||||
};
|
||||
if (!(isText || isPaid || isMembership || isGiftPurchase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
// Gift purchase announcements carry author data in the header.
|
||||
const badges = extractBadges(isGiftPurchase ? item.header?.author_badges : item.author?.badges);
|
||||
const isModerator = badges.some(b => b.type === 'moderator');
|
||||
const isMember = badges.some(b => b.type === 'member');
|
||||
const isVerified = badges.some(b => b.type === 'verified');
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let membershipLevel: string | undefined;
|
||||
if (isMembership) {
|
||||
const subtext: string = item.header_subtext?.text || '';
|
||||
const primaryText: string = item.header_primary_text?.text || '';
|
||||
// "Welcome to Level!" or "Upgraded membership to Level!"
|
||||
const levelMatch = subtext.match(/(?:Welcome to|Upgraded membership to)\s+(.+?)!/i);
|
||||
membershipLevel = levelMatch?.[1]?.trim() || primaryText || 'New member';
|
||||
}
|
||||
|
||||
let giftCount: number | undefined;
|
||||
if (isGiftPurchase) {
|
||||
// "Sent 5 Channel gift memberships"
|
||||
const countMatch = String(item.header?.primary_text?.text || '').match(/sent\s+(\d+)\s+/i);
|
||||
if (countMatch) {
|
||||
giftCount = parseInt(countMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
const authorChannelId = isGiftPurchase ? item.author_external_channel_id : item.author?.id;
|
||||
const authorName = isGiftPurchase
|
||||
? (item.header?.author_name?.text || 'Unknown')
|
||||
: String(item.author?.name ?? 'Unknown');
|
||||
const authorPhoto = isGiftPurchase
|
||||
? item.header?.author_photo?.[0]?.url
|
||||
: item.author?.thumbnails?.[0]?.url;
|
||||
|
||||
const membershipFallbackText = isMembership
|
||||
? (item.header_subtext?.text || item.header_primary_text?.text || '')
|
||||
: '';
|
||||
|
||||
const runs = resolveMessageRuns(item);
|
||||
|
||||
return {
|
||||
id: generateUniqueId(String(item.id ?? item.timestamp_usec ?? Date.now())),
|
||||
author: authorName,
|
||||
authorPhoto,
|
||||
authorChannelId: authorChannelId ? String(authorChannelId) : undefined,
|
||||
text: resolvedText || membershipFallbackText,
|
||||
runs: runs.length ? runs : undefined,
|
||||
publishedAt: resolveTimestamp(item.timestamp_usec ?? item.timestamp),
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
isModerator,
|
||||
isMember,
|
||||
isVerified,
|
||||
superChat: extractSuperChatInfo(item),
|
||||
membershipGift: isMembership,
|
||||
membershipGiftPurchase: isGiftPurchase,
|
||||
membershipLevel,
|
||||
giftCount,
|
||||
leaderboardRank: extractLeaderboardRank(item)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractLiveId } from './liveId';
|
||||
|
||||
const ID = 'dQw4w9WgXcQ';
|
||||
|
||||
test('bare id', () => {
|
||||
assert.equal(extractLiveId(ID), ID);
|
||||
assert.equal(extractLiveId(` ${ID}\n`), ID);
|
||||
});
|
||||
|
||||
test('watch URLs', () => {
|
||||
assert.equal(extractLiveId(`https://www.youtube.com/watch?v=${ID}`), ID);
|
||||
assert.equal(extractLiveId(`https://www.youtube.com/watch?v=${ID}&t=42s&feature=share`), ID);
|
||||
assert.equal(extractLiveId(`https://m.youtube.com/watch?feature=share&v=${ID}`), ID);
|
||||
assert.equal(extractLiveId(`youtube.com/watch?v=${ID}`), ID);
|
||||
});
|
||||
|
||||
test('short and live URLs', () => {
|
||||
assert.equal(extractLiveId(`https://youtu.be/${ID}`), ID);
|
||||
assert.equal(extractLiveId(`https://youtu.be/${ID}?si=abc123`), ID);
|
||||
assert.equal(extractLiveId(`https://www.youtube.com/live/${ID}`), ID);
|
||||
assert.equal(extractLiveId(`https://www.youtube.com/live/${ID}?feature=share`), ID);
|
||||
assert.equal(extractLiveId(`https://www.youtube.com/embed/${ID}`), ID);
|
||||
});
|
||||
|
||||
test('junk', () => {
|
||||
assert.equal(extractLiveId(undefined), '');
|
||||
assert.equal(extractLiveId(''), '');
|
||||
assert.equal(extractLiveId('hello world'), '');
|
||||
assert.equal(extractLiveId('dQw4w9WgXc'), '');
|
||||
assert.equal(extractLiveId('https://www.youtube.com/'), '');
|
||||
assert.equal(extractLiveId('https://www.youtube.com/watch?v=short'), '');
|
||||
assert.equal(extractLiveId(`https://example.com/${ID}`), '');
|
||||
assert.equal(extractLiveId('http://[bad'), '');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
|
||||
const YOUTUBE_HOST = /(^|\.)(youtube\.com|youtube-nocookie\.com|youtu\.be)$/;
|
||||
|
||||
/**
|
||||
* Extracts the 11-character video id from a bare id or any YouTube URL
|
||||
* (watch?v=, youtu.be/, /live/, /embed/, with or without scheme). Returns '' for anything else.
|
||||
*/
|
||||
export function extractLiveId(input: string | undefined): string {
|
||||
const trimmed = (input ?? '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (VIDEO_ID.test(trimmed)) return trimmed;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (!YOUTUBE_HOST.test(url.hostname)) return '';
|
||||
|
||||
const candidate = url.searchParams.get('v') ?? url.pathname.split('/').filter(Boolean).pop() ?? '';
|
||||
return VIDEO_ID.test(candidate) ? candidate : '';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false
|
||||
}
|
||||
"declaration": true
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
root: true
|
||||
extends: next/core-web-vitals
|
||||
rules:
|
||||
# Images come from the local proxy and the client is a static export; next/image adds nothing here.
|
||||
"@next/next/no-img-element": off
|
||||
+163
-709
@@ -1,761 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import type { ChatMessage, Poll } from '@shared/chat';
|
||||
import { useTimezone } from '../../lib/TimezoneContext';
|
||||
import { formatTimestamp } from '../../lib/timezone';
|
||||
import { proxyImageUrl } from '../../lib/imageProxy';
|
||||
import { Gift, MessageSquare, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
import { api } from '../../lib/api';
|
||||
import { useEvents } from '../../lib/useEvents';
|
||||
import { ChatList } from '../../components/ChatList';
|
||||
import { ConnectForm } from '../../components/ConnectForm';
|
||||
import { MessageCard } from '../../components/MessageCard';
|
||||
import { OnAir } from '../../components/OnAir';
|
||||
import { OverlaySettings } from '../../components/OverlaySettings';
|
||||
import { TopBar } from '../../components/TopBar';
|
||||
|
||||
// URL regex for detecting links (http/https)
|
||||
const URL_REGEX = /(https?:\/\/[^\s]+)/gi;
|
||||
type Toast = { id: number; text: string };
|
||||
|
||||
function parseTextWithLinks(text: string): Array<{ type: 'text' | 'link'; content: string }> {
|
||||
const parts: Array<{ type: 'text' | 'link'; content: string }> = [];
|
||||
let lastIndex = 0;
|
||||
const matches = text.matchAll(URL_REGEX);
|
||||
|
||||
for (const match of matches) {
|
||||
const url = match[0];
|
||||
const index = match.index!;
|
||||
|
||||
// Add text before the URL
|
||||
if (index > lastIndex) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex, index) });
|
||||
}
|
||||
|
||||
// Add the URL
|
||||
parts.push({ type: 'link', content: url });
|
||||
lastIndex = index + url.length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', content: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: 'text', content: text }];
|
||||
function matches(message: ChatMessage, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
return message.author.toLowerCase().includes(needle) || message.text.toLowerCase().includes(needle);
|
||||
}
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
|
||||
const POLL_INTERVAL = 2500;
|
||||
|
||||
// Global singleton to prevent multiple SSE connections across all renders/remounts
|
||||
let globalSSEConnection: EventSource | null = null;
|
||||
let globalConnectionListeners: Set<(payload: any) => void> = new Set();
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { messages, refresh, error: pollError } = useChatMessages();
|
||||
const { selection, status: overlayStatus } = useOverlaySelection();
|
||||
const { poll: currentPoll } = usePoll();
|
||||
const { connected, liveId, connect, disconnect, connecting } = useConnection();
|
||||
const [confirmUrl, setConfirmUrl] = useState<string | null>(null);
|
||||
const [superChatPulse, setSuperChatPulse] = useState(false);
|
||||
const [memberPulse, setMemberPulse] = useState(false);
|
||||
const [previouslySelected, setPreviouslySelected] = useState<Set<string>>(new Set());
|
||||
const prevSuperChatCountRef = useRef(0);
|
||||
const prevMemberCountRef = useRef(0);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const { stream, status, messages, selection, poll } = useEvents();
|
||||
const [search, setSearch] = useState('');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [linkToOpen, setLinkToOpen] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const shownBefore = useRef(new Set<string>());
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
const toast = useCallback((text: string) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((list) => [...list, { id, text }]);
|
||||
setTimeout(() => setToasts((list) => list.filter((item) => item.id !== id)), 4000);
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
async (message: ChatMessage) => {
|
||||
try {
|
||||
// If clicking the already-selected message, clear it
|
||||
if (selection?.id === message.id) {
|
||||
await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'DELETE' });
|
||||
await api.clearSelection();
|
||||
} else {
|
||||
// Otherwise, select the new message
|
||||
await fetch(`${BACKEND_URL}/overlay/selection`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: message.id })
|
||||
});
|
||||
|
||||
// Mark this message as previously selected
|
||||
setPreviouslySelected(prev => new Set([...prev, message.id]));
|
||||
shownBefore.current.add(message.id);
|
||||
await api.select(message.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select message', error);
|
||||
toast(`Could not update overlay: ${(error as Error).message}`);
|
||||
}
|
||||
},
|
||||
[selection]
|
||||
[selection, toast]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
const clearSelection = useCallback(async () => {
|
||||
try {
|
||||
await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'DELETE' });
|
||||
await api.clearSelection();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear selection', error);
|
||||
toast(`Could not clear overlay: ${(error as Error).message}`);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const connect = useCallback(async (liveId: string) => {
|
||||
setBusy(true);
|
||||
setConnectError(null);
|
||||
try {
|
||||
await api.connect(liveId);
|
||||
shownBefore.current.clear();
|
||||
} catch (error) {
|
||||
setConnectError((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const statusHint = useMemo(() => {
|
||||
if (pollError) return 'Backend unreachable';
|
||||
if (overlayStatus === 'connecting') return 'Connecting to overlay…';
|
||||
if (overlayStatus === 'error') return 'Overlay stream disconnected';
|
||||
return 'Live';
|
||||
}, [pollError, overlayStatus]);
|
||||
|
||||
const handleLinkClick = useCallback((url: string) => {
|
||||
setConfirmUrl(url);
|
||||
}, []);
|
||||
|
||||
const handleConfirmOpen = useCallback(() => {
|
||||
if (confirmUrl) {
|
||||
window.open(confirmUrl, '_blank', 'noopener,noreferrer');
|
||||
setConfirmUrl(null);
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
await api.disconnect();
|
||||
} catch (error) {
|
||||
toast(`Could not disconnect: ${(error as Error).message}`);
|
||||
}
|
||||
}, [confirmUrl]);
|
||||
}, [toast]);
|
||||
|
||||
const handleCancelOpen = useCallback(() => {
|
||||
setConfirmUrl(null);
|
||||
}, []);
|
||||
|
||||
const superChats = useMemo(() => messages.filter(m => m.superChat), [messages]);
|
||||
const newMembers = useMemo(() => messages.filter(m => m.membershipGift || m.membershipGiftPurchase), [messages]);
|
||||
const regularMessages = useMemo(() => messages.filter(m => !m.superChat && !m.membershipGift && !m.membershipGiftPurchase), [messages]);
|
||||
|
||||
// Initialize counts on first load without triggering pulse
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current && messages.length > 0) {
|
||||
prevSuperChatCountRef.current = superChats.length;
|
||||
prevMemberCountRef.current = newMembers.length;
|
||||
hasInitializedRef.current = true;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT');
|
||||
if (typing) {
|
||||
if (event.key === 'Escape') (target as HTMLElement).blur();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (linkToOpen) setLinkToOpen(null);
|
||||
else if (settingsOpen) setSettingsOpen(false);
|
||||
else if (selection) void clearSelection();
|
||||
} else if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
} else if (event.key.toLowerCase() === 'p') {
|
||||
setPaused((value) => !value);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selection, linkToOpen, settingsOpen, clearSelection]);
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
const groups = useMemo(() => {
|
||||
const chat: ChatMessage[] = [];
|
||||
const supers: ChatMessage[] = [];
|
||||
const members: ChatMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (!matches(message, needle)) continue;
|
||||
if (message.superChat) supers.push(message);
|
||||
else if (message.membershipGift || message.membershipGiftPurchase) members.push(message);
|
||||
else chat.push(message);
|
||||
}
|
||||
}, [messages.length, superChats.length, newMembers.length]);
|
||||
return { chat, supers, members };
|
||||
}, [messages, needle]);
|
||||
|
||||
// Detect new superchats and trigger pulse
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current) return;
|
||||
const renderCard = (message: ChatMessage) => (
|
||||
<MessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
selected={selection?.id === message.id}
|
||||
wasSelected={selection?.id !== message.id && shownBefore.current.has(message.id)}
|
||||
onSelect={select}
|
||||
onLink={setLinkToOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const prevCount = prevSuperChatCountRef.current;
|
||||
const currentCount = superChats.length;
|
||||
|
||||
// Trigger pulse if count increased
|
||||
if (currentCount > prevCount) {
|
||||
setSuperChatPulse(true);
|
||||
const timer = setTimeout(() => setSuperChatPulse(false), 10000);
|
||||
prevSuperChatCountRef.current = currentCount;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
prevSuperChatCountRef.current = currentCount;
|
||||
}, [superChats.length]);
|
||||
|
||||
// Detect new members and trigger pulse
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRef.current) return;
|
||||
|
||||
const prevCount = prevMemberCountRef.current;
|
||||
const currentCount = newMembers.length;
|
||||
|
||||
// Trigger pulse if count increased
|
||||
if (currentCount > prevCount) {
|
||||
setMemberPulse(true);
|
||||
const timer = setTimeout(() => setMemberPulse(false), 10000);
|
||||
prevMemberCountRef.current = currentCount;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
prevMemberCountRef.current = currentCount;
|
||||
}, [newMembers.length]);
|
||||
// Mock mode feeds messages while disconnected; keep the dashboard up in that case.
|
||||
const showConnect = (status.state === 'disconnected' || status.state === 'connecting') && messages.length === 0;
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
{!connected && (
|
||||
<div className="connectionPrompt">
|
||||
<div className="connectionPrompt__content">
|
||||
<h2>Connect to YouTube Live Stream</h2>
|
||||
<form className="connectionPrompt__form" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const liveId = formData.get('liveId') as string;
|
||||
if (liveId.trim()) {
|
||||
connect(liveId.trim());
|
||||
}
|
||||
}}>
|
||||
<input
|
||||
name="liveId"
|
||||
type="text"
|
||||
placeholder="Enter YouTube Live Stream ID or URL"
|
||||
disabled={connecting}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={connecting}>
|
||||
{connecting ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && (
|
||||
<main className="dash">
|
||||
{showConnect ? (
|
||||
<ConnectForm
|
||||
busy={busy || status.state === 'connecting'}
|
||||
error={connectError ?? (stream === 'error' ? 'Backend is not reachable. Is it running?' : status.error ?? null)}
|
||||
onConnect={connect}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<header className="dashboard__tabs">
|
||||
{currentPoll && (
|
||||
<div className="poll-indicator" title="Active poll on YouTube">
|
||||
<span className="poll-indicator__icon">📊</span>
|
||||
<span className="poll-indicator__text">Active Poll</span>
|
||||
<TopBar
|
||||
status={status}
|
||||
stream={stream}
|
||||
poll={poll}
|
||||
counts={{ chat: groups.chat.length, super: groups.supers.length, members: groups.members.length }}
|
||||
search={search}
|
||||
searchRef={searchRef}
|
||||
onSearch={setSearch}
|
||||
paused={paused}
|
||||
onTogglePause={() => setPaused((value) => !value)}
|
||||
onOverlaySettings={() => setSettingsOpen(true)}
|
||||
onDisconnect={disconnect}
|
||||
/>
|
||||
<OnAir selection={selection} onClear={clearSelection} />
|
||||
<section className="grid">
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<MessageSquare size={13} />
|
||||
Chat
|
||||
{needle && <span className="panel__note">filtered</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="tab">
|
||||
Messages <span className="tab__count">{regularMessages.length}</span>
|
||||
<ChatList messages={groups.chat} paused={paused} render={renderCard} empty={needle ? 'No messages match.' : 'Waiting for messages…'} />
|
||||
</div>
|
||||
<div className={`tab ${superChatPulse ? 'tab--pulse' : ''}`}>
|
||||
Superchats <span className="tab__count">{superChats.length}</span>
|
||||
</div>
|
||||
<div className={`tab ${memberPulse ? 'tab--pulse' : ''}`}>
|
||||
Members <span className="tab__count">{newMembers.length}</span>
|
||||
</div>
|
||||
<button className="btn-reset" onClick={disconnect} title="Reset stream connection">
|
||||
🔄
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="dashboard__grid">
|
||||
<div className="panel panel--chat">
|
||||
<div className="panel__header">
|
||||
<h2>💬 CHAT MESSAGES</h2>
|
||||
<button
|
||||
className="btn-clear-inline"
|
||||
onClick={handleClear}
|
||||
disabled={!selection}
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
</div>
|
||||
<ChatListPanel
|
||||
messages={regularMessages}
|
||||
renderItem={(message) => (
|
||||
<ChatItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isSelected={selection?.id === message.id}
|
||||
onSelect={() => handleSelect(message)}
|
||||
onLinkClick={handleLinkClick}
|
||||
isPreviouslySelected={previouslySelected.has(message.id)}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="chatList__empty">
|
||||
<p>⏳ Waiting for chat messages...</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dashboard__grid-right">
|
||||
<div className="panel panel--super">
|
||||
<div className="panel__header">
|
||||
<h2>🔥 SUPER CHATS</h2>
|
||||
<div className="side">
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<Sparkles size={13} />
|
||||
Super Chats
|
||||
</div>
|
||||
<ChatListPanel
|
||||
messages={superChats}
|
||||
renderItem={(message) => (
|
||||
<ChatItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isSelected={selection?.id === message.id}
|
||||
onSelect={() => handleSelect(message)}
|
||||
onLinkClick={handleLinkClick}
|
||||
isPreviouslySelected={previouslySelected.has(message.id)}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="chatList__empty">
|
||||
<p>No super chats yet</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ChatList messages={groups.supers} render={renderCard} empty="No Super Chats yet." />
|
||||
</div>
|
||||
|
||||
<div className="panel panel--members">
|
||||
<div className="panel__header">
|
||||
<h2>⭐ MEMBERSHIPS & MILESTONES</h2>
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<Gift size={13} />
|
||||
Memberships
|
||||
</div>
|
||||
<ChatListPanel
|
||||
messages={newMembers}
|
||||
renderItem={(message) => (
|
||||
<MemberItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isSelected={selection?.id === message.id}
|
||||
onSelect={() => handleSelect(message)}
|
||||
onLinkClick={handleLinkClick}
|
||||
isPreviouslySelected={previouslySelected.has(message.id)}
|
||||
/>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="chatList__empty">
|
||||
<p>No new members yet</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ChatList messages={groups.members} render={renderCard} empty="No new members yet." />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmUrl && (
|
||||
<div className="modal-overlay" onClick={handleCancelOpen}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="modal__title">Open External Link?</h3>
|
||||
<p className="modal__url">{confirmUrl}</p>
|
||||
{settingsOpen && <OverlaySettings onClose={() => setSettingsOpen(false)} />}
|
||||
|
||||
{linkToOpen && (
|
||||
<div className="modal" onClick={() => setLinkToOpen(null)}>
|
||||
<div className="modal__card" onClick={(event) => event.stopPropagation()}>
|
||||
<h2>Open this link?</h2>
|
||||
<p className="modal__url">{linkToOpen}</p>
|
||||
<div className="modal__actions">
|
||||
<button className="modal__btn modal__btn--cancel" onClick={handleCancelOpen}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="modal__btn modal__btn--confirm" onClick={handleConfirmOpen}>
|
||||
Open Link
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setLinkToOpen(null)}>Cancel</button>
|
||||
<a className="btn btn--primary" href={linkToOpen} target="_blank" rel="noopener noreferrer" onClick={() => setLinkToOpen(null)}>
|
||||
Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toasts.length > 0 && (
|
||||
<div className="toasts">
|
||||
{toasts.map((item) => (
|
||||
<div key={item.id} className="toast">{item.text}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function useChatMessages() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/chat/messages`);
|
||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
setMessages(Array.isArray(data.messages) ? data.messages : []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { messages, refresh, error };
|
||||
}
|
||||
|
||||
type OverlayStatus = 'connecting' | 'live' | 'error';
|
||||
|
||||
type SelectionPayload = {
|
||||
message: ChatMessage | null;
|
||||
};
|
||||
|
||||
function useOverlaySelection() {
|
||||
const [selection, setSelection] = useState<ChatMessage | null>(null);
|
||||
const [status, setStatus] = useState<OverlayStatus>('connecting');
|
||||
const listenerRef = useRef<((payload: any) => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Create global connection if it doesn't exist
|
||||
if (!globalSSEConnection) {
|
||||
console.log('[Dashboard] Creating GLOBAL SSE connection');
|
||||
globalSSEConnection = new EventSource(`${BACKEND_URL}/overlay/stream`);
|
||||
|
||||
globalSSEConnection.addEventListener('selection', ((event: MessageEvent) => {
|
||||
try {
|
||||
const payload: SelectionPayload = JSON.parse(event.data);
|
||||
// Broadcast to all listeners
|
||||
globalConnectionListeners.forEach(listener => listener(payload));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse selection payload', error);
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
globalSSEConnection.addEventListener('heartbeat', () => {
|
||||
// Heartbeat - connection is alive
|
||||
});
|
||||
|
||||
globalSSEConnection.onerror = (error) => {
|
||||
console.error('[Dashboard] SSE connection error - will auto-reconnect', error);
|
||||
// EventSource automatically reconnects, no action needed
|
||||
};
|
||||
}
|
||||
|
||||
// Register this component's listener
|
||||
const myListener = (payload: SelectionPayload) => {
|
||||
setSelection(payload.message);
|
||||
setStatus('live');
|
||||
};
|
||||
|
||||
listenerRef.current = myListener;
|
||||
globalConnectionListeners.add(myListener);
|
||||
|
||||
return () => {
|
||||
// Unregister this component's listener
|
||||
if (listenerRef.current) {
|
||||
globalConnectionListeners.delete(listenerRef.current);
|
||||
}
|
||||
// Don't close global connection - other components might use it
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { selection, status };
|
||||
}
|
||||
|
||||
function useConnection() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [liveId, setLiveId] = useState<string | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
const checkStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/health`);
|
||||
const data = await response.json();
|
||||
setConnected(data.connected);
|
||||
setLiveId(data.liveId);
|
||||
} catch (error) {
|
||||
console.error('Failed to check connection status', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkStatus]);
|
||||
|
||||
const connect = useCallback(async (liveId: string) => {
|
||||
setConnecting(true);
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/chat/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ liveId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to connect');
|
||||
}
|
||||
|
||||
await checkStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to connect', error);
|
||||
alert('Failed to connect to YouTube Live chat. Please check the Live ID.');
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [checkStatus]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
await fetch(`${BACKEND_URL}/chat/disconnect`, { method: 'POST' });
|
||||
await checkStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect', error);
|
||||
}
|
||||
}, [checkStatus]);
|
||||
|
||||
return { connected, liveId, connect, disconnect, connecting };
|
||||
}
|
||||
|
||||
function usePoll() {
|
||||
const [poll, setPoll] = useState<Poll | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Create SSE connection for polls
|
||||
const eventSource = new EventSource(`${BACKEND_URL}/poll/stream`);
|
||||
|
||||
eventSource.addEventListener('poll', ((event: MessageEvent) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
setPoll(payload.poll);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse poll payload', error);
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Heartbeat - connection is alive
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('[Dashboard] Poll SSE connection error', error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { poll };
|
||||
}
|
||||
|
||||
type ConnectionControlProps = {
|
||||
connected: boolean;
|
||||
liveId: string | null;
|
||||
connecting: boolean;
|
||||
onConnect: (liveId: string) => void;
|
||||
onDisconnect: () => void;
|
||||
inline?: boolean;
|
||||
};
|
||||
|
||||
function ConnectionControl({ connected, liveId, connecting, onConnect, onDisconnect, inline }: ConnectionControlProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputValue.trim()) {
|
||||
onConnect(inputValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"connectionControl" + (inline ? " connectionControl--inline" : "") }>
|
||||
<div className="connectionControl__content">
|
||||
{connected ? (
|
||||
<div className="connectionControl__connected">
|
||||
<div className="connectionControl__info">
|
||||
<span className="connectionControl__badge">🟢 Connected</span>
|
||||
<span className="connectionControl__liveId">Live ID: {liveId}</span>
|
||||
</div>
|
||||
<button className="btn-disconnect" onClick={onDisconnect}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="connectionControl__form" onSubmit={handleSubmit}>
|
||||
<div className="connectionControl__input">
|
||||
<label htmlFor="liveId">YouTube Live Stream ID or URL</label>
|
||||
<input
|
||||
id="liveId"
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="e.g., dQw4w9WgXcQ or https://youtube.com/watch?v=..."
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-connect"
|
||||
disabled={connecting || !inputValue.trim()}
|
||||
>
|
||||
{connecting ? 'Connecting...' : 'Connect to Stream'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatItemProps = {
|
||||
message: ChatMessage;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onLinkClick: (url: string) => void;
|
||||
isPreviouslySelected?: boolean;
|
||||
};
|
||||
|
||||
function MessageText({ text, onLinkClick }: { text: string; onLinkClick: (url: string) => void }) {
|
||||
const parts = parseTextWithLinks(text);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.type === 'link' ? (
|
||||
<a
|
||||
key={i}
|
||||
href="#"
|
||||
className="message-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLinkClick(part.content);
|
||||
}}
|
||||
>
|
||||
{part.content}
|
||||
</a>
|
||||
) : (
|
||||
<span key={i}>{part.content}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatListPanelProps = {
|
||||
messages: ChatMessage[];
|
||||
renderItem: (message: ChatMessage) => React.ReactNode;
|
||||
emptyState: React.ReactNode;
|
||||
};
|
||||
|
||||
function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const isAtBottom = node.scrollHeight - node.scrollTop - node.clientHeight <= 150;
|
||||
wasAtBottomRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
node.addEventListener('scroll', handleScroll);
|
||||
return () => node.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current;
|
||||
if (node && wasAtBottomRef.current) {
|
||||
// Use setTimeout with requestAnimationFrame for more reliable scrolling
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (node && wasAtBottomRef.current) {
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="chatList" ref={scrollRef}>
|
||||
{messages.length > 0 ? messages.map(renderItem) : emptyState}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySelected }: ChatItemProps) {
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const className = [
|
||||
'chatItem',
|
||||
isSelected ? 'chatItem--active' : '',
|
||||
isPreviouslySelected ? 'chatItem--previously-selected' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="chatItem__header">
|
||||
{message.authorPhoto && (
|
||||
<img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="chatItem__avatar" />
|
||||
)}
|
||||
<div className="chatItem__meta">
|
||||
<div className="chatItem__authorLine">
|
||||
{message.membershipLevel && (
|
||||
<span className="chatItem__membership">{message.membershipLevel}:</span>
|
||||
)}
|
||||
<span className="chatItem__author">{message.author}</span>
|
||||
{message.leaderboardRank && (
|
||||
<span className="badge badge--leaderboard" title={`#${message.leaderboardRank} on leaderboard`}>
|
||||
👑 #{message.leaderboardRank}
|
||||
</span>
|
||||
)}
|
||||
{message.badges && message.badges.map((badge, i) => (
|
||||
badge.imageUrl ? (
|
||||
<img key={i} src={proxyImageUrl(badge.imageUrl)} alt={badge.label} className="badge badge--image" title={badge.label} />
|
||||
) : (
|
||||
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
|
||||
{badge.type === 'moderator' && '🛡️'}
|
||||
{badge.type === 'member' && '⭐'}
|
||||
{badge.type === 'verified' && '✓'}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
{message.superChat && (
|
||||
<span className="chatItem__superchat-inline" style={{ backgroundColor: message.superChat.color }}>
|
||||
{message.superChat.currency}{message.superChat.currency ? ' ' : ''}{message.superChat.amount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<time className="chatItem__time">{formatTimestamp(message.publishedAt, timezone)}</time>
|
||||
</div>
|
||||
</div>
|
||||
{message.runs?.length ? (
|
||||
<p className="chatItem__text">
|
||||
{message.runs.map((r, i) =>
|
||||
r.emojiUrl ? (
|
||||
<img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="chatItem__emoji" />
|
||||
) : (
|
||||
<span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
) : message.text && message.text !== 'N/A' && (
|
||||
<p className="chatItem__text">
|
||||
<MessageText text={message.text} onLinkClick={onLinkClick} />
|
||||
</p>
|
||||
)}
|
||||
{message.superChat?.stickerUrl && (
|
||||
<div className="chatItem__sticker">
|
||||
<img
|
||||
src={proxyImageUrl(message.superChat.stickerUrl)}
|
||||
alt={message.superChat.stickerAlt || 'Super Sticker'}
|
||||
className="chatItem__stickerImage"
|
||||
onError={(e) => {
|
||||
// Hide image on error
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberItem({ message, isSelected, onSelect, onLinkClick, isPreviouslySelected }: ChatItemProps) {
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const className = [
|
||||
'memberItem',
|
||||
isSelected ? 'memberItem--active' : '',
|
||||
isPreviouslySelected ? 'memberItem--previously-selected' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="memberItem__header">
|
||||
{message.authorPhoto && (
|
||||
<img src={proxyImageUrl(message.authorPhoto)} alt={message.author} className="memberItem__avatar" />
|
||||
)}
|
||||
<div className="memberItem__info">
|
||||
<span className="memberItem__author">{message.author}</span>
|
||||
<span className="memberItem__level">
|
||||
{message.membershipGiftPurchase && message.giftCount
|
||||
? `Sent ${message.giftCount} gift membership${message.giftCount > 1 ? 's' : ''}`
|
||||
: message.membershipLevel || 'New member'}
|
||||
</span>
|
||||
<time className="memberItem__time">{formatTimestamp(message.publishedAt, timezone)}</time>
|
||||
</div>
|
||||
</div>
|
||||
{message.runs?.length ? (
|
||||
<p className="memberItem__text">
|
||||
{message.runs.map((r, i) =>
|
||||
r.emojiUrl ? (
|
||||
<img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="memberItem__emoji" />
|
||||
) : (
|
||||
<span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
) : message.text && message.text !== 'N/A' && (
|
||||
<p className="memberItem__text">
|
||||
<MessageText text={message.text} onLinkClick={onLinkClick} />
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+977
-1242
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,15 @@
|
||||
import './globals.css';
|
||||
import type { ReactNode } from 'react';
|
||||
import { TimezoneProvider } from '../lib/TimezoneContext';
|
||||
|
||||
export const metadata = {
|
||||
title: 'YouTube Chat Client',
|
||||
description: 'High-performance YouTube Live chat dashboard'
|
||||
title: 'YTChatHub',
|
||||
description: 'YouTube Live chat dashboard with an OBS overlay'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<TimezoneProvider>
|
||||
{children}
|
||||
</TimezoneProvider>
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-189
@@ -1,211 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
import { Badges } from '../../components/Badges';
|
||||
import { MessageBody } from '../../components/MessageBody';
|
||||
import { formatAmount } from '../../lib/format';
|
||||
import { proxyImageUrl } from '../../lib/imageProxy';
|
||||
import { DEFAULT_OPTIONS, parseOverlayOptions, type OverlayOptions } from '../../lib/overlayOptions';
|
||||
import { useEvents } from '../../lib/useEvents';
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
|
||||
const SWAP_MS = 220;
|
||||
|
||||
type SelectionPayload = {
|
||||
message: ChatMessage | null;
|
||||
};
|
||||
function membershipLabel(message: ChatMessage): string {
|
||||
if (message.membershipGiftPurchase && message.giftCount) {
|
||||
return `Gifted ${message.giftCount} membership${message.giftCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
return message.membershipLevel || 'New member';
|
||||
}
|
||||
|
||||
export default function OverlayPage() {
|
||||
const [message, setMessage] = useState<ChatMessage | null>(null);
|
||||
const [displayMessage, setDisplayMessage] = useState<ChatMessage | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [fadingOut, setFadingOut] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const connectionRef = useRef<EventSource | null>(null);
|
||||
const switchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { selection } = useEvents();
|
||||
const [options, setOptions] = useState<OverlayOptions>(DEFAULT_OPTIONS);
|
||||
const [shown, setShown] = useState<ChatMessage | null>(null);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [expired, setExpired] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent multiple connections
|
||||
if (connectionRef.current) {
|
||||
console.log('[Overlay] SSE already connected');
|
||||
setOptions(parseOverlayOptions(window.location.search));
|
||||
document.body.classList.add('is-overlay');
|
||||
return () => document.body.classList.remove('is-overlay');
|
||||
}, []);
|
||||
|
||||
// Swap with a short exit animation so a new pick never pops in over the old one.
|
||||
useEffect(() => {
|
||||
if ((selection?.id ?? null) === (shown?.id ?? null)) return;
|
||||
if (!shown || options.anim === 'none') {
|
||||
setShown(selection);
|
||||
setExpired(false);
|
||||
return;
|
||||
}
|
||||
setLeaving(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShown(selection);
|
||||
setExpired(false);
|
||||
setLeaving(false);
|
||||
}, SWAP_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selection, shown, options.anim]);
|
||||
|
||||
console.log('[Overlay] Creating SSE connection');
|
||||
const source = new EventSource(`${BACKEND_URL}/overlay/stream`);
|
||||
connectionRef.current = source;
|
||||
|
||||
const onSelection = (event: MessageEvent) => {
|
||||
try {
|
||||
const payload: SelectionPayload = JSON.parse(event.data);
|
||||
setMessage(payload.message);
|
||||
setConnected(true);
|
||||
} catch (error) {
|
||||
console.error('overlay: failed to parse payload', error);
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('selection', onSelection as EventListener);
|
||||
source.addEventListener('heartbeat', () => setConnected(true));
|
||||
source.onerror = () => setConnected(false);
|
||||
|
||||
return () => {
|
||||
console.log('[Overlay] Closing SSE connection');
|
||||
source.removeEventListener('selection', onSelection as EventListener);
|
||||
source.close();
|
||||
connectionRef.current = null;
|
||||
if (switchTimeoutRef.current) {
|
||||
clearTimeout(switchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependency array - only connect once
|
||||
|
||||
// Handle message transitions
|
||||
useEffect(() => {
|
||||
// Clear any pending timeout
|
||||
if (switchTimeoutRef.current) {
|
||||
clearTimeout(switchTimeoutRef.current);
|
||||
switchTimeoutRef.current = null;
|
||||
}
|
||||
if (!options.hide || !shown) return;
|
||||
const timer = setTimeout(() => setExpired(true), options.hide * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [shown, options.hide]);
|
||||
|
||||
if (message === null && displayMessage !== null) {
|
||||
// Deselecting - fade out
|
||||
setFadingOut(true);
|
||||
setSwitching(false);
|
||||
switchTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayMessage(null);
|
||||
setFadingOut(false);
|
||||
}, 300);
|
||||
} else if (message !== null && displayMessage !== null && message.id !== displayMessage.id) {
|
||||
// Switching between messages - fade out then fade in
|
||||
setSwitching(true);
|
||||
setFadingOut(true);
|
||||
switchTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayMessage(message);
|
||||
setFadingOut(false);
|
||||
setSwitching(false);
|
||||
}, 300);
|
||||
} else if (message !== null && displayMessage === null) {
|
||||
// First message - just show it
|
||||
setDisplayMessage(message);
|
||||
setFadingOut(false);
|
||||
setSwitching(false);
|
||||
}
|
||||
}, [message, displayMessage]);
|
||||
const message = expired ? null : shown;
|
||||
const kind = message?.superChat ? 'super' : message?.membershipGift || message?.membershipGiftPurchase ? 'member' : 'chat';
|
||||
|
||||
return (
|
||||
<main className="overlay">
|
||||
{displayMessage ? (
|
||||
<div className={`overlay__card ${fadingOut ? 'overlay__card--fadeOut' : ''}`}>
|
||||
{displayMessage.superChat ? (
|
||||
<>
|
||||
<div className="overlay__superchat-header" style={{ backgroundColor: displayMessage.superChat.color }}>
|
||||
{displayMessage.authorPhoto && (
|
||||
<img src={proxyImageUrl(displayMessage.authorPhoto)} alt={displayMessage.author} className="overlay__superchat-avatar" />
|
||||
)}
|
||||
<span className="overlay__superchat-name">{displayMessage.author}</span>
|
||||
<span className="overlay__superchat-separator"> - </span>
|
||||
<span className="overlay__superchat-amount">
|
||||
{displayMessage.superChat.currency}{displayMessage.superChat.currency ? ' ' : ''}{displayMessage.superChat.amount}
|
||||
</span>
|
||||
</div>
|
||||
{displayMessage.runs?.length ? (
|
||||
<p className="overlay__superchat-text">
|
||||
{displayMessage.runs.map((r, i) =>
|
||||
r.emojiUrl ? (
|
||||
<img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
|
||||
) : (
|
||||
<span key={i}>{r.text}</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
) : displayMessage.text && displayMessage.text !== 'N/A' && (
|
||||
<p className="overlay__superchat-text">{displayMessage.text}</p>
|
||||
)}
|
||||
{displayMessage.superChat.stickerUrl && (
|
||||
<div className="overlay__sticker">
|
||||
<img
|
||||
src={proxyImageUrl(displayMessage.superChat.stickerUrl)}
|
||||
alt={displayMessage.superChat.stickerAlt || 'Super Sticker'}
|
||||
className="overlay__stickerImage"
|
||||
onError={(e) => {
|
||||
// Hide image on error
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (displayMessage.membershipGift || displayMessage.membershipGiftPurchase) ? (
|
||||
<>
|
||||
<div className="overlay__membership-header">
|
||||
{displayMessage.authorPhoto && (
|
||||
<img src={proxyImageUrl(displayMessage.authorPhoto)} alt={displayMessage.author} className="overlay__membership-avatar" />
|
||||
)}
|
||||
<div className="overlay__membership-info">
|
||||
<span className="overlay__membership-name">{displayMessage.author}</span>
|
||||
<span className="overlay__membership-separator"> - </span>
|
||||
<span className="overlay__membership-level">
|
||||
{displayMessage.membershipGiftPurchase && displayMessage.giftCount
|
||||
? `Sent ${displayMessage.giftCount} Gift Membership${displayMessage.giftCount > 1 ? 's' : ''}`
|
||||
: displayMessage.membershipLevel || 'New Member'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{displayMessage.runs?.length ? (
|
||||
<p className="overlay__membership-text">
|
||||
{displayMessage.runs.map((r, i) =>
|
||||
r.emojiUrl ? (
|
||||
<img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
|
||||
) : (
|
||||
<span key={i}>{r.text}</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
) : displayMessage.text && displayMessage.text !== 'N/A' && (
|
||||
<p className="overlay__membership-text">{displayMessage.text}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="overlay__header">
|
||||
{displayMessage.authorPhoto && (
|
||||
<img src={proxyImageUrl(displayMessage.authorPhoto)} alt={displayMessage.author} className="overlay__avatar" />
|
||||
)}
|
||||
<div>
|
||||
<div className="overlay__authorLine">
|
||||
<span className="overlay__author">{displayMessage.author}</span>
|
||||
{displayMessage.leaderboardRank && (
|
||||
<span className="overlay__badge overlay__badge--leaderboard" title={`#${displayMessage.leaderboardRank} on leaderboard`}>
|
||||
👑 #{displayMessage.leaderboardRank}
|
||||
</span>
|
||||
)}
|
||||
{displayMessage.badges && displayMessage.badges.map((badge, i) => (
|
||||
badge.imageUrl ? (
|
||||
<img key={i} src={proxyImageUrl(badge.imageUrl)} alt={badge.label} className="overlay__badge overlay__badge--image" title={badge.label} />
|
||||
) : (
|
||||
<span key={i} className={`overlay__badge overlay__badge--${badge.type}`} title={badge.label}>
|
||||
{badge.type === 'moderator' && '🛡️'}
|
||||
{badge.type === 'member' && '⭐'}
|
||||
{badge.type === 'verified' && '✓'}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{displayMessage.runs?.length ? (
|
||||
<p className="overlay__text">
|
||||
{displayMessage.runs.map((r, i) =>
|
||||
r.emojiUrl ? (
|
||||
<img key={i} src={proxyImageUrl(r.emojiUrl)} alt={r.emojiAlt || 'emoji'} className="overlay__emoji" />
|
||||
) : (
|
||||
<span key={i}>{r.text}</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
) : displayMessage.text && displayMessage.text !== 'N/A' && (
|
||||
<p className="overlay__text">{displayMessage.text}</p>
|
||||
)}
|
||||
</>
|
||||
<main className={`ov ov--${options.theme} ov--${options.pos} ov--size-${options.size} ov--anim-${options.anim}`} style={{ ['--w' as string]: String(options.width) }}>
|
||||
{message && (
|
||||
<div key={message.id} className={`ov__card ov__card--${kind} ${leaving ? 'ov__card--leaving' : ''}`} style={message.superChat ? { ['--accent' as string]: message.superChat.color } : undefined}>
|
||||
<div className="ov__head">
|
||||
{message.authorPhoto && <img src={proxyImageUrl(message.authorPhoto)} alt="" className="ov__avatar" />}
|
||||
<span className="ov__author">{message.author}</span>
|
||||
<Badges message={message} />
|
||||
{message.superChat && <span className="ov__amount">{formatAmount(message.superChat.amount, message.superChat.currency)}</span>}
|
||||
{kind === 'member' && <span className="ov__level">{membershipLabel(message)}</span>}
|
||||
</div>
|
||||
<MessageBody message={message} className="ov__text" />
|
||||
{message.superChat?.stickerUrl && (
|
||||
<img
|
||||
src={proxyImageUrl(message.superChat.stickerUrl)}
|
||||
alt={message.superChat.stickerAlt || 'Super Sticker'}
|
||||
className="ov__sticker"
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-9
@@ -1,17 +1,22 @@
|
||||
import Link from 'next/link';
|
||||
import { LayoutDashboard, MonitorPlay } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="landing">
|
||||
<section className="panel">
|
||||
<h1>YouTube Chat Client</h1>
|
||||
<p>
|
||||
Launch the dashboard to monitor live chat and control the overlay that feeds OBS.
|
||||
</p>
|
||||
<Link className="primary" href="/dashboard">
|
||||
Open Dashboard
|
||||
</Link>
|
||||
<p className="muted">Overlay preview lives at /overlay for the OBS browser source.</p>
|
||||
<section className="landing__card">
|
||||
<h1>YTChatHub</h1>
|
||||
<p>Watch a YouTube Live chat, pick a message, and it appears on your OBS overlay.</p>
|
||||
<div className="landing__links">
|
||||
<Link className="btn btn--primary" href="/dashboard/">
|
||||
<LayoutDashboard size={15} />
|
||||
Open dashboard
|
||||
</Link>
|
||||
<Link className="btn" href="/overlay/">
|
||||
<MonitorPlay size={15} />
|
||||
Overlay for OBS
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BadgeCheck, Crown, Shield, Star, Tv } from 'lucide-react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
import { proxyImageUrl } from '../lib/imageProxy';
|
||||
|
||||
const ICONS = {
|
||||
moderator: Shield,
|
||||
member: Star,
|
||||
verified: BadgeCheck,
|
||||
owner: Tv,
|
||||
custom: null
|
||||
} as const;
|
||||
|
||||
export function Badges({ message }: { message: ChatMessage }) {
|
||||
return (
|
||||
<>
|
||||
{message.leaderboardRank && (
|
||||
<span className="badge badge--rank" title={`#${message.leaderboardRank} on the chat leaderboard`}>
|
||||
<Crown size={11} strokeWidth={2.5} />
|
||||
{message.leaderboardRank}
|
||||
</span>
|
||||
)}
|
||||
{message.badges?.map((badge, i) => {
|
||||
if (badge.imageUrl) {
|
||||
return <img key={i} src={proxyImageUrl(badge.imageUrl)} alt={badge.label ?? badge.type} title={badge.label} className="badge badge--image" />;
|
||||
}
|
||||
const Icon = ICONS[badge.type];
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
|
||||
<Icon size={12} strokeWidth={2.5} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
|
||||
type Props = {
|
||||
messages: ChatMessage[];
|
||||
paused?: boolean;
|
||||
render: (message: ChatMessage) => ReactNode;
|
||||
empty: string;
|
||||
};
|
||||
|
||||
const BOTTOM_THRESHOLD = 80;
|
||||
|
||||
/**
|
||||
* Scroll container that follows new messages while the user is at the bottom.
|
||||
* Scrolling up stops following and shows a jump button with the unseen count.
|
||||
* While paused the list is frozen at its current contents.
|
||||
*/
|
||||
export function ChatList({ messages, paused = false, render, empty }: Props) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const following = useRef(true);
|
||||
const [unseen, setUnseen] = useState(0);
|
||||
const [frozen, setFrozen] = useState<ChatMessage[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFrozen(paused ? messages : null);
|
||||
// Only the pause flag should snapshot; message updates while paused must not refresh the snapshot.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paused]);
|
||||
|
||||
const shown = frozen ?? messages;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
if (following.current) {
|
||||
node.scrollTop = node.scrollHeight;
|
||||
setUnseen(0);
|
||||
} else {
|
||||
setUnseen((count) => count + 1);
|
||||
}
|
||||
}, [shown]);
|
||||
|
||||
const onScroll = () => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight <= BOTTOM_THRESHOLD;
|
||||
following.current = atBottom;
|
||||
if (atBottom) setUnseen(0);
|
||||
};
|
||||
|
||||
const jump = () => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
following.current = true;
|
||||
node.scrollTop = node.scrollHeight;
|
||||
setUnseen(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="list">
|
||||
<div className="list__scroll" ref={scroller} onScroll={onScroll}>
|
||||
{shown.length ? shown.map(render) : <p className="list__empty">{empty}</p>}
|
||||
</div>
|
||||
{unseen > 0 && !following.current && (
|
||||
<button type="button" className="list__jump" onClick={jump}>
|
||||
<ArrowDown size={14} />
|
||||
{unseen} new
|
||||
</button>
|
||||
)}
|
||||
{paused && frozen && messages.length > frozen.length && (
|
||||
<span className="list__paused">Paused, {messages.length - frozen.length} waiting</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Radio } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
|
||||
type Props = {
|
||||
busy: boolean;
|
||||
error?: string | null;
|
||||
onConnect: (liveId: string) => void;
|
||||
};
|
||||
|
||||
export function ConnectForm({ busy, error, onConnect }: Props) {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (value.trim()) onConnect(value.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="connect">
|
||||
<form className="connect__card" onSubmit={submit}>
|
||||
<span className="connect__icon">
|
||||
<Radio size={22} />
|
||||
</span>
|
||||
<h1>Connect to a live stream</h1>
|
||||
<p>Paste the video URL or the 11 character video id of a stream that is live now.</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="https://youtube.com/watch?v=…"
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error && <p className="connect__error">{error}</p>}
|
||||
<button type="submit" className="btn btn--primary" disabled={busy || !value.trim()}>
|
||||
{busy ? 'Connecting…' : 'Connect'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import type { ChatMessage, MessageRun } from '@shared/chat';
|
||||
import { proxyImageUrl } from '../lib/imageProxy';
|
||||
import { splitLinks } from '../lib/format';
|
||||
|
||||
type Props = {
|
||||
message: ChatMessage;
|
||||
className?: string;
|
||||
/** When given, links are clickable and call this instead of navigating. When omitted links render as text. */
|
||||
onLink?: (url: string) => void;
|
||||
};
|
||||
|
||||
function runsOf(message: ChatMessage): MessageRun[] {
|
||||
if (message.runs?.length) return message.runs;
|
||||
if (message.text && message.text !== 'N/A') return [{ text: message.text }];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function MessageBody({ message, className, onLink }: Props) {
|
||||
const runs = runsOf(message);
|
||||
if (!runs.length) return null;
|
||||
|
||||
const link = (url: string, label: string, key: number) =>
|
||||
onLink ? (
|
||||
<a
|
||||
key={key}
|
||||
href={url}
|
||||
className="link"
|
||||
title={url}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onLink(url);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span key={key} className="link">{label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<p className={className}>
|
||||
{runs.map((run, i) => {
|
||||
if (run.emojiUrl) {
|
||||
return <img key={i} src={proxyImageUrl(run.emojiUrl)} alt={run.emojiAlt || 'emoji'} className="emoji" loading="lazy" />;
|
||||
}
|
||||
if (run.url) return link(run.url, run.text || run.url, i);
|
||||
return splitLinks(run.text ?? '').map((part, j) =>
|
||||
part.type === 'link' ? link(part.content, part.content, i * 1000 + j) : <span key={i * 1000 + j}>{part.content}</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Gift } from 'lucide-react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
import { proxyImageUrl } from '../lib/imageProxy';
|
||||
import { formatAmount, formatTime } from '../lib/format';
|
||||
import { Badges } from './Badges';
|
||||
import { MessageBody } from './MessageBody';
|
||||
|
||||
type Props = {
|
||||
message: ChatMessage;
|
||||
selected: boolean;
|
||||
wasSelected: boolean;
|
||||
onSelect: (message: ChatMessage) => void;
|
||||
onLink: (url: string) => void;
|
||||
};
|
||||
|
||||
function membershipLabel(message: ChatMessage): string {
|
||||
if (message.membershipGiftPurchase && message.giftCount) {
|
||||
return `Gifted ${message.giftCount} membership${message.giftCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
return message.membershipLevel || 'New member';
|
||||
}
|
||||
|
||||
export function MessageCard({ message, selected, wasSelected, onSelect, onLink }: Props) {
|
||||
const kind = message.superChat ? 'super' : message.membershipGift || message.membershipGiftPurchase ? 'member' : 'chat';
|
||||
const className = ['card', `card--${kind}`, selected && 'card--selected', wasSelected && 'card--seen'].filter(Boolean).join(' ');
|
||||
const accent = message.superChat?.color;
|
||||
|
||||
return (
|
||||
<button type="button" className={className} onClick={() => onSelect(message)} style={accent ? { ['--accent' as string]: accent } : undefined}>
|
||||
<div className="card__head">
|
||||
{message.authorPhoto ? (
|
||||
<img src={proxyImageUrl(message.authorPhoto)} alt="" className="avatar" loading="lazy" />
|
||||
) : (
|
||||
<span className="avatar avatar--empty">{message.author.slice(0, 1)}</span>
|
||||
)}
|
||||
<span className="card__author">{message.author}</span>
|
||||
<Badges message={message} />
|
||||
{message.superChat && <span className="card__amount">{formatAmount(message.superChat.amount, message.superChat.currency)}</span>}
|
||||
{kind === 'member' && (
|
||||
<span className="card__level">
|
||||
<Gift size={11} strokeWidth={2.5} />
|
||||
{membershipLabel(message)}
|
||||
</span>
|
||||
)}
|
||||
<time className="card__time">{formatTime(message.publishedAt)}</time>
|
||||
</div>
|
||||
<MessageBody message={message} className="card__text" onLink={onLink} />
|
||||
{message.superChat?.stickerUrl && (
|
||||
<img
|
||||
src={proxyImageUrl(message.superChat.stickerUrl)}
|
||||
alt={message.superChat.stickerAlt || 'Super Sticker'}
|
||||
className="sticker"
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
import { proxyImageUrl } from '../lib/imageProxy';
|
||||
import { formatAmount } from '../lib/format';
|
||||
import { MessageBody } from './MessageBody';
|
||||
|
||||
type Props = { selection: ChatMessage | null; onClear: () => void };
|
||||
|
||||
/** Mirrors what the OBS overlay is showing right now, so the operator never has to guess. */
|
||||
export function OnAir({ selection, onClear }: Props) {
|
||||
return (
|
||||
<section className={`onair ${selection ? 'onair--live' : ''}`}>
|
||||
<span className="onair__label">On air</span>
|
||||
{selection ? (
|
||||
<>
|
||||
{selection.authorPhoto && <img src={proxyImageUrl(selection.authorPhoto)} alt="" className="avatar avatar--sm" />}
|
||||
<span className="onair__author">{selection.author}</span>
|
||||
{selection.superChat && (
|
||||
<span className="card__amount" style={{ background: selection.superChat.color }}>
|
||||
{formatAmount(selection.superChat.amount, selection.superChat.currency)}
|
||||
</span>
|
||||
)}
|
||||
<MessageBody message={selection} className="onair__text" />
|
||||
<button type="button" className="btn btn--sm" onClick={onClear} title="Clear the overlay (Esc)">
|
||||
<X size={13} />
|
||||
Clear
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="onair__hint">Nothing on the overlay. Click a message to show it, click again to hide it.</span>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Copy, ExternalLink, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ANIMATIONS, buildOverlayUrl, DEFAULT_OPTIONS, POSITIONS, SIZES, THEMES, type OverlayOptions } from '../lib/overlayOptions';
|
||||
|
||||
const STORAGE_KEY = 'ytchathub.overlay';
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
bl: 'Bottom left', bc: 'Bottom center', br: 'Bottom right',
|
||||
tl: 'Top left', tc: 'Top center', tr: 'Top right',
|
||||
s: 'Small', m: 'Medium', l: 'Large', xl: 'Extra large',
|
||||
dark: 'Dark', light: 'Light', blueprint: 'Blueprint (light, brand)', glass: 'Glass', youtube: 'YouTube red',
|
||||
fade: 'Fade', slide: 'Slide', none: 'None'
|
||||
};
|
||||
|
||||
function load(): OverlayOptions {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return { ...DEFAULT_OPTIONS, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
// storage unavailable or corrupt
|
||||
}
|
||||
return DEFAULT_OPTIONS;
|
||||
}
|
||||
|
||||
export function OverlaySettings({ onClose }: { onClose: () => void }) {
|
||||
const [options, setOptions] = useState<OverlayOptions>(DEFAULT_OPTIONS);
|
||||
const [origin, setOrigin] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(load());
|
||||
setOrigin(window.location.origin);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(options));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
const url = buildOverlayUrl(origin, options);
|
||||
const set = <K extends keyof OverlayOptions>(key: K, value: OverlayOptions[K]) => setOptions((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// clipboard blocked; the URL is still selectable in the field
|
||||
}
|
||||
};
|
||||
|
||||
const select = <K extends 'theme' | 'pos' | 'size' | 'anim'>(key: K, label: string, values: readonly OverlayOptions[K][]) => (
|
||||
<label className="field">
|
||||
<span>{label}</span>
|
||||
<select value={options[key]} onChange={(event) => set(key, event.target.value as OverlayOptions[K])}>
|
||||
{values.map((value) => (
|
||||
<option key={value} value={value}>{LABELS[value] ?? value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal__card modal__card--wide" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal__head">
|
||||
<h2>Overlay for OBS</h2>
|
||||
<button type="button" className="btn btn--icon" onClick={onClose} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">Add the URL below as a Browser Source. Settings are stored in the URL, so one source can have its own look.</p>
|
||||
<div className="fields">
|
||||
{select('theme', 'Theme', THEMES)}
|
||||
{select('pos', 'Position', POSITIONS)}
|
||||
{select('size', 'Text size', SIZES)}
|
||||
{select('anim', 'Animation', ANIMATIONS)}
|
||||
<label className="field">
|
||||
<span>Hide after (seconds, 0 = never)</span>
|
||||
<input type="number" min={0} max={600} value={options.hide} onChange={(event) => set('hide', Math.max(0, Number(event.target.value) || 0))} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Max width (px on a 1080p canvas, scales with source size)</span>
|
||||
<input type="number" min={240} max={1920} step={20} value={options.width} onChange={(event) => set('width', Math.max(240, Number(event.target.value) || 240))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="urlbox">
|
||||
<input readOnly value={url} onFocus={(event) => event.target.select()} />
|
||||
<button type="button" className="btn" onClick={copy}>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<a className="btn" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={14} />
|
||||
Open
|
||||
</a>
|
||||
</div>
|
||||
<p className="muted">Recommended Browser Source size: 1920 × 1080, with “Shutdown source when not visible” off so the connection stays warm.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { BarChart3, MonitorPlay, Pause, Play, Search, Unplug } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { ConnectionStatus, Poll } from '@shared/chat';
|
||||
import type { StreamState } from '../lib/useEvents';
|
||||
|
||||
type Props = {
|
||||
status: ConnectionStatus;
|
||||
stream: StreamState;
|
||||
poll: Poll | null;
|
||||
counts: { chat: number; super: number; members: number };
|
||||
search: string;
|
||||
searchRef: RefObject<HTMLInputElement | null>;
|
||||
onSearch: (value: string) => void;
|
||||
paused: boolean;
|
||||
onTogglePause: () => void;
|
||||
onOverlaySettings: () => void;
|
||||
onDisconnect: () => void;
|
||||
};
|
||||
|
||||
function statusLabel(status: ConnectionStatus, stream: StreamState): { text: string; tone: string } {
|
||||
if (stream === 'error') return { text: 'Backend unreachable', tone: 'danger' };
|
||||
if (stream === 'connecting') return { text: 'Connecting to backend', tone: 'muted' };
|
||||
switch (status.state) {
|
||||
case 'live':
|
||||
return { text: 'Live', tone: 'live' };
|
||||
case 'connecting':
|
||||
return { text: 'Connecting', tone: 'warn' };
|
||||
case 'reconnecting':
|
||||
return { text: 'Reconnecting', tone: 'warn' };
|
||||
default:
|
||||
return { text: status.error ? `Disconnected: ${status.error}` : 'Disconnected', tone: 'danger' };
|
||||
}
|
||||
}
|
||||
|
||||
export function TopBar({ status, stream, poll, counts, search, searchRef, onSearch, paused, onTogglePause, onOverlaySettings, onDisconnect }: Props) {
|
||||
const label = statusLabel(status, stream);
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__stream">
|
||||
<span className={`dot dot--${label.tone}`} />
|
||||
<span className="topbar__title" title={status.liveId ?? undefined}>{status.title || status.liveId || 'No stream'}</span>
|
||||
<span className={`chip chip--${label.tone}`}>{label.text}</span>
|
||||
{poll?.active && (
|
||||
<span className="chip chip--poll" title="A poll is running on YouTube">
|
||||
<BarChart3 size={12} />
|
||||
Poll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="search">
|
||||
<Search size={14} />
|
||||
<input ref={searchRef} value={search} onChange={(event) => onSearch(event.target.value)} placeholder="Search author or text ( / )" spellCheck={false} />
|
||||
</label>
|
||||
|
||||
<div className="topbar__stats">
|
||||
<span title="Chat messages kept">{counts.chat} chat</span>
|
||||
<span title="Super Chats this session">{counts.super} super</span>
|
||||
<span title="Memberships this session">{counts.members} members</span>
|
||||
</div>
|
||||
|
||||
<div className="topbar__actions">
|
||||
<button type="button" className={`btn btn--icon ${paused ? 'btn--active' : ''}`} onClick={onTogglePause} title={paused ? 'Resume chat (P)' : 'Pause chat (P)'}>
|
||||
{paused ? <Play size={16} /> : <Pause size={16} />}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onOverlaySettings} title="Overlay URL and look">
|
||||
<MonitorPlay size={15} />
|
||||
Overlay
|
||||
</button>
|
||||
<button type="button" className="btn btn--icon btn--danger" onClick={onDisconnect} title="Disconnect from this stream">
|
||||
<Unplug size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getBrowserTimezone } from './timezone';
|
||||
|
||||
interface TimezoneContextType {
|
||||
timezone: string;
|
||||
setTimezone: (timezone: string) => void;
|
||||
}
|
||||
|
||||
const TimezoneContext = createContext<TimezoneContextType | undefined>(undefined);
|
||||
|
||||
export function TimezoneProvider({ children }: { children: React.ReactNode }) {
|
||||
// Initialize with detected timezone immediately to avoid empty string
|
||||
const [timezone, setTimezone] = useState<string>(() => {
|
||||
try {
|
||||
return getBrowserTimezone();
|
||||
} catch (error) {
|
||||
console.warn('Failed to detect timezone on initialization:', error);
|
||||
return 'UTC';
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Re-detect browser timezone on mount (in case it changed)
|
||||
const detectedTimezone = getBrowserTimezone();
|
||||
setTimezone(detectedTimezone);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TimezoneContext.Provider value={{ timezone, setTimezone }}>
|
||||
{children}
|
||||
</TimezoneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTimezone() {
|
||||
const context = useContext(TimezoneContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTimezone must be used within a TimezoneProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BACKEND_URL } from './config';
|
||||
|
||||
async function call<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${BACKEND_URL}${path}`, init);
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (body?.error) message = String(body.error);
|
||||
} catch {
|
||||
// body was not JSON
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const json = (body: unknown): RequestInit => ({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
export const api = {
|
||||
connect: (liveId: string) => call<{ ok: true; liveId: string }>('/chat/connect', json({ liveId })),
|
||||
disconnect: () => call('/chat/disconnect', { method: 'POST' }),
|
||||
select: (id: string) => call('/overlay/selection', json({ id })),
|
||||
clearSelection: () => call('/overlay/selection', { method: 'DELETE' })
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Backend origin. Empty means same origin, which is the case when the backend serves the built client. */
|
||||
export const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL ?? '').replace(/\/$/, '');
|
||||
@@ -0,0 +1,27 @@
|
||||
export function formatTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/gi;
|
||||
|
||||
export type TextPart = { type: 'text' | 'link'; content: string };
|
||||
|
||||
/** Splits plain text into text and link parts. Used only for runs YouTube did not mark as links. */
|
||||
export function splitLinks(text: string): TextPart[] {
|
||||
const parts: TextPart[] = [];
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(URL_PATTERN)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > last) parts.push({ type: 'text', content: text.slice(last, index) });
|
||||
parts.push({ type: 'link', content: match[0] });
|
||||
last = index + match[0].length;
|
||||
}
|
||||
if (last < text.length) parts.push({ type: 'text', content: text.slice(last) });
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function formatAmount(amount: string, currency: string): string {
|
||||
return currency ? `${currency} ${amount}` : amount;
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
|
||||
import { BACKEND_URL } from './config';
|
||||
|
||||
/**
|
||||
* Converts a YouTube CDN image URL to use our backend proxy
|
||||
* This prevents 429 rate limit errors from YouTube's CDN
|
||||
*/
|
||||
const PROXIED_HOSTS = new Set(['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com', 'lh3.googleusercontent.com']);
|
||||
|
||||
/** Routes YouTube CDN images through the backend cache so OBS and the dashboard do not hit CDN rate limits. */
|
||||
export function proxyImageUrl(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
|
||||
// Check if it's a YouTube CDN URL
|
||||
const youtubeCdnDomains = ['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com'];
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (youtubeCdnDomains.includes(urlObj.hostname)) {
|
||||
// Proxy through our backend
|
||||
const parsed = new URL(url);
|
||||
if (PROXIED_HOSTS.has(parsed.hostname)) {
|
||||
return `${BACKEND_URL}/proxy/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, return as-is
|
||||
return url;
|
||||
}
|
||||
|
||||
// Return non-YouTube URLs as-is
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export const THEMES = ['dark', 'light', 'blueprint', 'glass', 'youtube'] as const;
|
||||
export const POSITIONS = ['bl', 'bc', 'br', 'tl', 'tc', 'tr'] as const;
|
||||
export const SIZES = ['s', 'm', 'l', 'xl'] as const;
|
||||
export const ANIMATIONS = ['fade', 'slide', 'none'] as const;
|
||||
|
||||
export type OverlayOptions = {
|
||||
theme: (typeof THEMES)[number];
|
||||
pos: (typeof POSITIONS)[number];
|
||||
size: (typeof SIZES)[number];
|
||||
anim: (typeof ANIMATIONS)[number];
|
||||
/** Seconds before the overlay hides the message by itself. 0 keeps it until cleared. */
|
||||
hide: number;
|
||||
/** Max card width in pixels. */
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_OPTIONS: OverlayOptions = { theme: 'dark', pos: 'bl', size: 'm', anim: 'fade', hide: 0, width: 640 };
|
||||
|
||||
function pick<T extends readonly string[]>(list: T, value: string | null, fallback: T[number]): T[number] {
|
||||
return value && (list as readonly string[]).includes(value) ? (value as T[number]) : fallback;
|
||||
}
|
||||
|
||||
export function parseOverlayOptions(search: string): OverlayOptions {
|
||||
const params = new URLSearchParams(search);
|
||||
const hide = Number(params.get('hide'));
|
||||
const width = Number(params.get('w'));
|
||||
return {
|
||||
theme: pick(THEMES, params.get('theme'), DEFAULT_OPTIONS.theme),
|
||||
pos: pick(POSITIONS, params.get('pos'), DEFAULT_OPTIONS.pos),
|
||||
size: pick(SIZES, params.get('size'), DEFAULT_OPTIONS.size),
|
||||
anim: pick(ANIMATIONS, params.get('anim'), DEFAULT_OPTIONS.anim),
|
||||
hide: Number.isFinite(hide) && hide >= 0 ? Math.floor(hide) : DEFAULT_OPTIONS.hide,
|
||||
width: Number.isFinite(width) && width >= 240 ? Math.floor(width) : DEFAULT_OPTIONS.width
|
||||
};
|
||||
}
|
||||
|
||||
/** Only non-default values go in the URL so the plain /overlay/ link keeps working. */
|
||||
export function buildOverlayUrl(origin: string, options: OverlayOptions): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options.theme !== DEFAULT_OPTIONS.theme) params.set('theme', options.theme);
|
||||
if (options.pos !== DEFAULT_OPTIONS.pos) params.set('pos', options.pos);
|
||||
if (options.size !== DEFAULT_OPTIONS.size) params.set('size', options.size);
|
||||
if (options.anim !== DEFAULT_OPTIONS.anim) params.set('anim', options.anim);
|
||||
if (options.hide !== DEFAULT_OPTIONS.hide) params.set('hide', String(options.hide));
|
||||
if (options.width !== DEFAULT_OPTIONS.width) params.set('w', String(options.width));
|
||||
const query = params.toString();
|
||||
return `${origin}/overlay/${query ? `?${query}` : ''}`;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Timezone utilities for handling browser timezone detection and formatting
|
||||
*/
|
||||
|
||||
export function getBrowserTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch (error) {
|
||||
console.warn('Failed to detect browser timezone, falling back to UTC:', error);
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimestamp(
|
||||
isoString: string,
|
||||
timezone?: string,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string {
|
||||
const date = new Date(isoString);
|
||||
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
...options
|
||||
};
|
||||
|
||||
const targetTimezone = timezone || getBrowserTimezone();
|
||||
|
||||
// If no valid timezone, fall back to local time
|
||||
if (!targetTimezone || targetTimezone === '') {
|
||||
console.warn('No valid timezone provided, using local time');
|
||||
return date.toLocaleTimeString('en-US', defaultOptions);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = new Intl.DateTimeFormat('en-US', {
|
||||
...defaultOptions,
|
||||
timeZone: targetTimezone
|
||||
}).format(date);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('Failed to format timestamp with timezone, falling back to local:', error);
|
||||
|
||||
// Try GMT+3 as a fallback if the detected timezone fails
|
||||
try {
|
||||
const gmt3Result = new Intl.DateTimeFormat('en-US', {
|
||||
...defaultOptions,
|
||||
timeZone: 'Europe/Istanbul' // GMT+3
|
||||
}).format(date);
|
||||
return gmt3Result;
|
||||
} catch (gmt3Error) {
|
||||
console.warn('GMT+3 fallback also failed:', gmt3Error);
|
||||
return date.toLocaleTimeString('en-US', defaultOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimestampWithDate(
|
||||
isoString: string,
|
||||
timezone?: string,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string {
|
||||
const date = new Date(isoString);
|
||||
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
...options
|
||||
};
|
||||
|
||||
const targetTimezone = timezone || getBrowserTimezone();
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
...defaultOptions,
|
||||
timeZone: targetTimezone
|
||||
}).format(date);
|
||||
} catch (error) {
|
||||
console.warn('Failed to format timestamp with timezone, falling back to local:', error);
|
||||
return date.toLocaleString('en-US', defaultOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import type { ChatMessage, ConnectionStatus, Poll, ServerEvent, ServerEventType } from '@shared/chat';
|
||||
import { BACKEND_URL } from './config';
|
||||
|
||||
export type StreamState = 'connecting' | 'open' | 'error';
|
||||
|
||||
export type EventsState = {
|
||||
stream: StreamState;
|
||||
status: ConnectionStatus;
|
||||
messages: ChatMessage[];
|
||||
selection: ChatMessage | null;
|
||||
poll: Poll | null;
|
||||
};
|
||||
|
||||
const MAX_REGULAR = 200;
|
||||
const EVENT_TYPES: ServerEventType[] = ['init', 'message', 'selection', 'poll', 'status', 'clear'];
|
||||
|
||||
const initialState: EventsState = {
|
||||
stream: 'connecting',
|
||||
status: { state: 'disconnected', liveId: null },
|
||||
messages: [],
|
||||
selection: null,
|
||||
poll: null
|
||||
};
|
||||
|
||||
export function isSpecial(message: ChatMessage): boolean {
|
||||
return !!(message.superChat || message.membershipGift || message.membershipGiftPurchase);
|
||||
}
|
||||
|
||||
/** Appends a message and drops the oldest regular ones past the cap. Special messages are always kept. */
|
||||
function append(list: ChatMessage[], message: ChatMessage): ChatMessage[] {
|
||||
const next = [...list, message];
|
||||
let regular = 0;
|
||||
for (const item of next) if (!isSpecial(item)) regular += 1;
|
||||
let drop = regular - MAX_REGULAR;
|
||||
if (drop <= 0) return next;
|
||||
return next.filter((item) => {
|
||||
if (drop > 0 && !isSpecial(item)) {
|
||||
drop -= 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
type Action = ServerEvent | { type: 'stream'; stream: StreamState };
|
||||
|
||||
function reducer(state: EventsState, action: Action): EventsState {
|
||||
switch (action.type) {
|
||||
case 'stream':
|
||||
return { ...state, stream: action.stream };
|
||||
case 'init':
|
||||
return { ...state, status: action.status, messages: action.messages, selection: action.selection, poll: action.poll };
|
||||
case 'message':
|
||||
return { ...state, messages: append(state.messages, action.message) };
|
||||
case 'selection':
|
||||
return { ...state, selection: action.message };
|
||||
case 'poll':
|
||||
return { ...state, poll: action.poll };
|
||||
case 'status':
|
||||
return { ...state, status: action.status };
|
||||
case 'clear':
|
||||
return { ...state, messages: [], selection: null, poll: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/** Single SSE subscription to the backend. EventSource reconnects on its own and the server replays state on `init`. */
|
||||
export function useEvents(): EventsState {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`${BACKEND_URL}/events`);
|
||||
const onEvent = (event: Event) => {
|
||||
try {
|
||||
dispatch(JSON.parse((event as MessageEvent).data));
|
||||
} catch (error) {
|
||||
console.error('Bad event payload', error);
|
||||
}
|
||||
};
|
||||
for (const type of EVENT_TYPES) source.addEventListener(type, onEvent);
|
||||
source.onopen = () => dispatch({ type: 'stream', stream: 'open' });
|
||||
source.onerror = () => dispatch({ type: 'stream', stream: 'error' });
|
||||
return () => source.close();
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb'
|
||||
}
|
||||
}
|
||||
// Static export: the backend serves `client/out` so production runs on a single port.
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true }
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 765 KiB |
@@ -1,46 +0,0 @@
|
||||
# Active Context
|
||||
|
||||
## Current Focus
|
||||
- **Live YouTube integration active**: Backend connects to real YouTube Live chat via Innertube
|
||||
- **Dark minimalist UI redesign**: Clean dashboard with dark theme (#0a0a0a background), tab-based navigation, grid layout, and minimal spacing
|
||||
- **Connection flow**: Shows connection prompt on startup, disappears after connecting to YouTube Live stream
|
||||
- **Rich message parsing**: Full support for superchats, memberships, badges (moderator, member, verified)
|
||||
- **Timezone-aware timestamps**: All message timestamps display in user's local timezone with proper browser detection
|
||||
- **Visual selection feedback**: Previously selected messages show dimmed state for better user experience
|
||||
- **Image proxy implemented**: Backend proxies all YouTube CDN images with caching to prevent 429 rate limit errors
|
||||
|
||||
## Recent Decisions
|
||||
- Fixed CORS issues by setting headers on raw response object after `reply.hijack()` for SSE endpoint
|
||||
- Added `.env` loading via `tsx --env-file=.env` for YouTube Live ID configuration
|
||||
- Enhanced `ChatMessage` type to include badges, author photos, superchat info, and membership status
|
||||
- Redesigned dashboard with gradient backgrounds, centered layout, and modern glass-morphism effects
|
||||
- Separated overlay preview into its own highlighted section that only appears when a message is selected
|
||||
- Centered overlay message card content so live chat, superchat, and membership boxes align for OBS
|
||||
- **UI Layout Fixed**: Adjusted the dashboard grid to eliminate unnecessary space and properly align content. The Super Chat card is now left-aligned for better visual consistency.
|
||||
- **Currency Parsing**: Improved the backend logic to correctly parse Super Chat amounts and currencies, including for non-standard formats like TRY. The hardcoded 'USD' fallback has been removed.
|
||||
- **Conditional Auto-Scroll**: Implemented intelligent auto-scrolling that only activates when the user is at the bottom of the chat, preventing interruptions when reading older messages.
|
||||
- **Overlay Redesign**: The overlay has been completely restyled to match the dashboard's compact, dark theme with optimized spacing (30-40% more compact).
|
||||
- **Overlay Timestamps Removed**: Removed timestamps from overlay for cleaner OBS display while keeping them in dashboard.
|
||||
- **Gifted Memberships**: The 'Memberships & Milestones' panel now correctly displays the user who purchased the gift, not the recipient.
|
||||
- **Timestamp Resolution Fixed**: Resolved critical issue where timestamps showed 1970 epoch time. Now uses `timestamp_usec` (microseconds) from YouTube data and converts properly to user's local timezone.
|
||||
- **Timezone Support**: Implemented browser timezone detection and proper timestamp formatting across dashboard using `Intl.DateTimeFormat` with GMT+3 fallback.
|
||||
- **Visual Selection States**: Added three-tier visual feedback system - active (selected), normal, and previously-selected (dimmed) states for better user experience.
|
||||
- **UI Polish**: Fixed pulse animations on initial load, hidden N/A messages.
|
||||
- **Image Proxy**: Added `/proxy/image` endpoint in backend with in-memory caching (24hr TTL, max 1000 images) to prevent YouTube CDN 429 rate limit errors. All avatars, badges, and emojis now route through proxy with stale-on-error fallback.
|
||||
- **Message Switching Animation**: Added smooth fade-out/fade-in transitions when switching between selected messages in overlay (300ms duration).
|
||||
- **Smart Message Preservation**: Implemented intelligent message trimming - regular chat messages limited to 200, but superchats and memberships preserved for entire session. Trimming happens on every message to prevent loss of special messages.
|
||||
- **Super Sticker Support**: Added full support for super sticker image display. Backend parser extracts sticker URL and accessibility label from `item.sticker` array in YouTube data. Added `stickerUrl` and `stickerAlt` fields to `SuperChatInfo` type. Image proxy whitelist updated to include `lh3.googleusercontent.com` domain. Dashboard and overlay both render super stickers with 144x144px max dimensions, centered layout, and graceful error handling that hides broken images. Protocol-relative URLs (`//domain.com`) are automatically converted to HTTPS.
|
||||
- **Leaderboard Badge Support**: Implemented YouTube leaderboard rank display (Top Chatter feature). Backend parser extracts rank from `before_content_buttons` array with CROWN icon, parsing rank number from title field (e.g., "#3"). Added `leaderboardRank` field to `ChatMessage` type. Dashboard and overlay render leaderboard badge with crown emoji (👑) and rank number, styled with golden background (rgba(251, 191, 36, 0.2)) and yellow text (#fcd34d) for prominence.
|
||||
- **Task Master AI Integration**: Initialized Task Master AI with OpenRouter's x-ai/grok-code-fast-1 (main), google/gemini-2.5-pro (research), and google/gemini-2.5-flash (fallback) models. Successfully completed Task 2 (Super Sticker Display), Task 3 (Leaderboard Badge), and Task 4 (Live Poll Indicator).
|
||||
- **Live Poll Indicator**: Added simple poll detection that shows a pulsing "📊 Active Poll" indicator in the dashboard header when a YouTube poll is active. Backend listens for `UpdateLiveChatPollAction` and `CloseLiveChatActionPanelAction`/`RemoveBannerForLiveChatCommand` events. Indicator automatically appears/disappears based on poll state. Note: YouTube's API does not provide live vote percentages, so the feature only indicates poll presence without showing results.
|
||||
|
||||
## Immediate Next Steps
|
||||
1. Implement user authentication via YouTube OAuth 2.0 (Task 5)
|
||||
2. Add persistent image cache with SQLite (Task 6)
|
||||
3. Develop overlay theme controls (Task 7)
|
||||
4. Add error recovery and reconnection logic for stream interruptions
|
||||
|
||||
## Open Questions
|
||||
- Whether to add message search/filtering UI controls
|
||||
- Whether to persist Innertube visitor data between runs
|
||||
- Should image cache be persistent across restarts?
|
||||
@@ -1,20 +0,0 @@
|
||||
# Product Context
|
||||
|
||||
## Why This Project Exists
|
||||
Live streamers rely on YouTube’s default chat panel, which can feel sluggish, cluttered, and unreliable during high-traffic moments. Streamers also need a smoother workflow to feature chat messages inside OBS without manual copy/paste or third-party widgets.
|
||||
|
||||
## Target Users
|
||||
- Primary: Single YouTube streamer operating their own broadcast setup.
|
||||
- Secondary: Community contributors who want to customize or extend the client for similar use cases.
|
||||
|
||||
## User Goals
|
||||
- Launch the app locally and connect to their live chat with minimal configuration.
|
||||
- View chat in a fast, filterable interface that highlights new messages clearly.
|
||||
- Select a message to instantly display in an overlay browser source within OBS.
|
||||
- Trust that the app will keep running throughout long streams without desync or crashes.
|
||||
|
||||
## Experience Principles
|
||||
- **Performance First:** Avoid lag by batching updates, minimizing re-renders, and offloading work to background processes.
|
||||
- **Operational Clarity:** Provide clear status indicators for connection health, rate limits, and overlay sync.
|
||||
- **Low Friction:** Onboarding should consist of OAuth login once and a simple start command.
|
||||
- **Extensible:** Keep architecture modular so advanced users can add features (moderation tools, multi-stream support) later.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Progress Tracker
|
||||
|
||||
## Phase 0 – Foundations
|
||||
- [x] Create Memory Bank documentation.
|
||||
- [x] Simplify project layout (single package, shared types via alias).
|
||||
- [x] Document setup instructions for the new command set.
|
||||
|
||||
## Phase 1 – Core Infrastructure
|
||||
- [x] Implement Innertube client bootstrap (retrieve context, manage continuation tokens).
|
||||
- [x] Build backend poller with full normalization and comprehensive message parsing.
|
||||
- [x] Expose REST+SSE endpoints for chat and overlay delivery.
|
||||
- [x] Fix CORS issues for cross-origin SSE connections.
|
||||
- [x] Parse badges (moderator, member, verified), superchats, and membership gifts.
|
||||
|
||||
## Phase 2 – Operator Dashboard
|
||||
- [x] Implement modern chat feed UI with live status indicators.
|
||||
- [x] Display user avatars, badges, and special message types (superchats, memberships).
|
||||
- [x] Provide message selection controls with visual feedback.
|
||||
- [x] Centered layout with gradient backgrounds and glass-morphism effects.
|
||||
- [x] Message count display and connection status.
|
||||
- [x] Conditional auto-scrolling for chat panels.
|
||||
- [x] Timezone-aware timestamp formatting for all messages.
|
||||
- [x] Visual selection state management (active, normal, previously-selected).
|
||||
- [x] Fix super sticker image not showing.
|
||||
- [x] Add chat leaderboard badge.
|
||||
- [x] Show active poll indicator.
|
||||
|
||||
## Phase 3 – OBS Overlay Experience
|
||||
- [x] Create overlay page that consumes SSE stream at `/overlay`.
|
||||
- [x] Style overlay with modern design including avatars, badges, and superchat displays.
|
||||
- [x] Ensure transparent background for OBS browser source.
|
||||
- [x] Display new member announcements on overlay.
|
||||
- [x] Optimize overlay spacing for efficient use (30-40% more compact).
|
||||
- [x] Remove timestamps from overlay for cleaner display.
|
||||
- [x] Add smooth fade transitions when switching between messages.
|
||||
|
||||
## Phase 4 – Reliability & Polish
|
||||
- [x] Implement image proxy with caching to prevent YouTube CDN 429 errors.
|
||||
- [x] Implement smart message storage that preserves superchats/memberships.
|
||||
- [ ] Add log-in and unique stream id for each logged in user
|
||||
- [ ] Do a security check
|
||||
- [ ] Refactor the code
|
||||
- [ ] Ensure the deployment.
|
||||
- [ ] Write tests (unit/integration) and contributor documentation.
|
||||
|
||||
## Phase 5 - Customization
|
||||
- [ ] Add customization options
|
||||
|
||||
## Current Status
|
||||
- **✅ YouTube Integration Live**: Backend connects to real YouTube Live chat and parses all message types
|
||||
- **✅ Modern UI Complete**: Dashboard features centered layout, gradient backgrounds, badges, and superchat displays
|
||||
- **✅ CORS Fixed**: Overlay SSE stream works cross-origin for OBS integration
|
||||
- **✅ Rich Parsing**: Moderators, members, verified users, superchats, and membership gifts all detected and displayed. Currency parsing is now more robust.
|
||||
- **✅ UI Polished**: The dashboard and overlay layouts have been refined for better spacing and alignment. Overlay is 30-40% more compact.
|
||||
- **✅ Auto-Scroll Implemented**: Chat panels now auto-scroll intelligently.
|
||||
- **✅ Timestamp Resolution Fixed**: Critical bug resolved - timestamps now display correct current time instead of 1970 epoch
|
||||
- **✅ Timezone Support**: Dashboard timestamps display in user's local timezone with browser detection and GMT+3 fallback
|
||||
- **✅ Visual Selection States**: Three-tier feedback system (active/normal/previously-selected) for better UX
|
||||
- **✅ Image Proxy Implemented**: Backend now proxies all YouTube CDN images (avatars, badges, emojis, super stickers) with 24hr in-memory cache to prevent 429 rate limit errors. Added lh3.googleusercontent.com domain for super stickers.
|
||||
- **✅ Overlay Animations**: Smooth fade-out/in transitions when switching between messages
|
||||
- **✅ Smart Storage**: Regular messages limited to 200, superchats and memberships preserved for entire session
|
||||
- **✅ Super Sticker Support**: Super sticker images now display correctly in both dashboard and overlay with proper error handling and accessibility labels
|
||||
- **✅ Leaderboard Badge Support**: YouTube leaderboard ranks (Top Chatter) now display with crown emoji and rank number next to usernames in both dashboard and overlay with golden styling
|
||||
- **✅ Live Poll Indicator**: Pulsing poll indicator shows in dashboard header when a YouTube poll is active, automatically hides when closed
|
||||
- **Next**: Implement user authentication, add persistent image cache, develop overlay theme controls
|
||||
@@ -1,21 +0,0 @@
|
||||
# Project Brief
|
||||
|
||||
## Vision
|
||||
Build a desktop-operated, open-source YouTube Live chat client that delivers a fast, reliable stream of messages and allows a streamer to spotlight a selected chat message on an OBS-ready overlay.
|
||||
|
||||
## Core Requirements
|
||||
- Mirror chat from a single YouTube Live stream with low latency and high stability.
|
||||
- Provide an operator dashboard to browse, search, and filter live chat messages.
|
||||
- Enable one-click selection of a message and broadcast it to an overlay page consumable in OBS.
|
||||
- Keep setup approachable: clone repo, configure environment variables/OAuth, and run locally.
|
||||
- Prioritize performance and resilience over advanced user management or multi-stream support.
|
||||
|
||||
## Constraints
|
||||
- Runs on the streamer’s personal computer; no external hosting assumed.
|
||||
- No end-user authentication beyond stored YouTube OAuth credentials.
|
||||
- Must tolerate YouTube API rate limits and intermittent network issues without crashing.
|
||||
|
||||
## Success Criteria
|
||||
- **Responsive Dashboard**: The operator dashboard remains responsive during long streams (>4 hours) without memory leaks.
|
||||
- **Instant Overlay Updates**: The OBS overlay updates in real-time when a message is selected.
|
||||
- **Comprehensive Documentation**: The project includes a detailed README and a well-maintained Memory Bank to facilitate easy setup and contribution.
|
||||
@@ -1,26 +0,0 @@
|
||||
# System Patterns
|
||||
|
||||
## Architecture Overview
|
||||
- **Project Layout:** Single pnpm package with three top-level folders: `client/` (Next.js dashboard + overlay), `backend/` (Node ingestion + realtime gateway), and `shared/` (typescript definitions shared between both).
|
||||
- **Data Flow:**
|
||||
1. Backend worker polls YouTube Live chat through the Innertube (`youtubei.js`) API, maintaining continuation tokens.
|
||||
2. Messages are stored in-memory (optionally persisted later) and emitted over an internal event bus.
|
||||
3. Client dashboard fetches chat data via REST and pushes selection updates via REST.
|
||||
4. Overlay page consumes a Server-Sent Events stream to stay in sync with the selected message.
|
||||
- **Realtime Delivery:** SSE for one-directional updates to OBS browser source; leave room to switch to WebSockets if we need bidirectional control later.
|
||||
|
||||
## Key Patterns & Practices
|
||||
- Abstract ingestion behind a module (`backend/src/ingestion/youtubei.ts`) so alternate providers (official API, headless browser) can be swapped in quickly.
|
||||
- Cache Innertube visitor data and API keys locally when we extend functionality, keeping startup fast and resilient to key rotations.
|
||||
- Use shared TypeScript definitions via the `@shared` path alias to maintain type safety across backend and client.
|
||||
- Centralized logging in the backend with structured payloads for easier debugging during long streams.
|
||||
- Graceful degradation: backoff strategies for fetch failures and mock-data fallback keep the UI usable even without credentials.
|
||||
- **Timezone Context Pattern**: React context provider (`TimezoneContext`) manages browser timezone detection and shares across components for consistent timestamp formatting.
|
||||
- **Selection State Management**: Three-tier visual state system (active/selected, normal, previously-selected) with CSS class composition for clear user feedback.
|
||||
- **Timestamp Resolution**: Backend uses `timestamp_usec` (microseconds) from YouTube data, converts to milliseconds, and frontend formats in user's local timezone.
|
||||
- **Image Proxy Pattern**: Backend `/proxy/image` endpoint caches YouTube CDN images (avatars, badges, emojis) with MD5-hashed keys, 24hr TTL, and 1000-image LRU eviction. Returns stale cache on 429 errors or network failures. Frontend `proxyImageUrl()` helper transparently rewrites YouTube CDN URLs to use proxy.
|
||||
- **Smart Message Storage**: Intelligent trimming system preserves all superchats and memberships for entire session while limiting regular messages to 200 most recent. Trimming occurs on every message addition to prevent special message loss. Uses order-preserving algorithm that identifies special messages by index and removes only oldest regular messages.
|
||||
- **Overlay Animation System**: Two-state approach with `message` (backend data) and `displayMessage` (UI state) enables smooth fade transitions when switching messages. 300ms fade-out followed by content swap and automatic fade-in.
|
||||
- **Super Sticker Parsing**: Backend `extractSuperChatInfo()` function detects super stickers via `item.sticker` array. Extracts largest image (144x144px) from sticker thumbnails array, handles protocol-relative URLs by converting to HTTPS, and captures accessibility labels for screen readers. Frontend conditionally renders stickers with error handling that hides broken images via `onError` handler.
|
||||
- **Leaderboard Badge Parsing**: Backend `extractLeaderboardRank()` function scans `before_content_buttons` array for CROWN icon entries. Extracts rank number from title field using regex pattern `/#(\d+)/`. Returns numeric rank or undefined if not present. Frontend displays badge with crown emoji and rank using golden color scheme for visual prominence.
|
||||
- **Poll Detection System**: Backend listens for `UpdateLiveChatPollAction` events to detect active polls and emits simple poll state (id + active flag) via SSE. Frontend displays pulsing purple poll indicator in dashboard header when poll is active. Poll closes automatically when backend receives `CloseLiveChatActionPanelAction` or `RemoveBannerForLiveChatCommand` events. Note: YouTube's live chat API does not provide vote percentages even with authentication, so only poll presence is indicated.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Tech Context
|
||||
|
||||
## Primary Stack
|
||||
- **Frontend:** Next.js 15 (App Router) + React 19 + TypeScript, styled with handcrafted CSS for now (Tailwind/shadcn still optional future add-ons).
|
||||
- **Backend Worker:** Node.js service run with `tsx`, using `youtubei.js` for Innertube chat ingestion, `fastify` + `@fastify/cors` for REST/SSE transport, and `eventemitter3` for internal pub/sub.
|
||||
- **Shared Types:** Simple TypeScript module in `shared/chat.ts`, imported via the `@shared/*` path alias defined in `tsconfig.base.json`.
|
||||
- **Realtime:** Server-Sent Events for overlay updates; WebSockets remain a future enhancement option.
|
||||
- **Timezone Handling:** Browser timezone detection via `Intl.DateTimeFormat().resolvedOptions().timeZone` with GMT+3 fallback for international users.
|
||||
|
||||
## Tooling & Commands
|
||||
- Single root `package.json`; `pnpm install` manages all deps.
|
||||
- `pnpm dev` runs backend (`tsx backend/src/index.ts`) and client (`next dev client`) concurrently via `concurrently`.
|
||||
- `pnpm build` compiles the backend with `tsc` and builds the Next app; `pnpm start:backend` / `pnpm start:client` serve production bundles separately.
|
||||
|
||||
## Environment & Dependencies
|
||||
- Requires `YOUTUBE_LIVE_ID` (or full URL) to enable real chat ingestion; omitted value triggers mock mode.
|
||||
- Optional overrides for Innertube API keys/version can be supplied through env vars when needed.
|
||||
- `better-sqlite3` is included for future persistence work but not yet wired in.
|
||||
|
||||
## Constraints & Considerations
|
||||
- Innertube endpoints may break; keep ingestion module adaptable and plan for a headless-browser fallback.
|
||||
- Project expected to run on the streamer’s machine; dependencies must remain cross-platform and avoid heavyweight native builds where possible.
|
||||
- No separate package boundaries anymore, so TypeScript path aliases and import hygiene are important to prevent tangled relative paths.
|
||||
+15
-16
@@ -1,40 +1,39 @@
|
||||
{
|
||||
"name": "youtube-client",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "High-performance YouTube Live chat client for streamers.",
|
||||
"description": "YouTube Live chat client for streamers with an OBS overlay.",
|
||||
"license": "MIT",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"pnpm dev:backend\" \"pnpm dev:client\"",
|
||||
"dev:backend": "tsx backend/src/index.ts",
|
||||
"dev:client": "next dev client",
|
||||
"build": "pnpm build:backend && pnpm build:client",
|
||||
"dev": "concurrently -n backend,client -c blue,magenta \"pnpm dev:backend\" \"pnpm dev:client\"",
|
||||
"dev:backend": "tsx watch backend/src/index.ts",
|
||||
"dev:client": "NEXT_PUBLIC_BACKEND_URL=http://localhost:4100 next dev client -p 3100",
|
||||
"build": "pnpm build:client && pnpm build:backend",
|
||||
"build:backend": "tsc -p backend/tsconfig.build.json",
|
||||
"build:client": "next build client",
|
||||
"start": "concurrently \"pnpm start:backend\" \"pnpm start:client\"",
|
||||
"start:backend": "node backend/dist/backend/src/index.js",
|
||||
"start:client": "next start client",
|
||||
"lint": "next lint client"
|
||||
"start": "node backend/dist/backend/src/index.js",
|
||||
"lint": "next lint client",
|
||||
"typecheck": "tsc -p backend/tsconfig.json --noEmit && tsc -p client/tsconfig.json --noEmit",
|
||||
"test": "tsx --test backend/src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"fastify": "^5.6.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"next": "^15.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"youtubei.js": "^15.1.1",
|
||||
"zod": "^4.1.11"
|
||||
"youtubei.js": "^15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.5",
|
||||
"@types/react": "19.2.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^8.2.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-next": "^15.5.4",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
Generated
+4004
File diff suppressed because it is too large
Load Diff
+34
-10
@@ -1,26 +1,27 @@
|
||||
export type Badge = {
|
||||
type: 'moderator' | 'member' | 'verified' | 'custom';
|
||||
type: 'moderator' | 'member' | 'verified' | 'owner' | 'custom';
|
||||
label?: string;
|
||||
icon?: string;
|
||||
imageUrl?: string; // For custom membership badges
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
export type SuperChatInfo = {
|
||||
amount: string;
|
||||
currency: string;
|
||||
color: string;
|
||||
stickerUrl?: string; // For super stickers
|
||||
stickerAlt?: string; // Accessibility label for super stickers
|
||||
stickerUrl?: string;
|
||||
stickerAlt?: string;
|
||||
};
|
||||
|
||||
export type MessageRun = {
|
||||
text?: string;
|
||||
/** Full destination URL when YouTube rendered this run as a link. The visible text may be truncated. */
|
||||
url?: string;
|
||||
emojiUrl?: string;
|
||||
emojiAlt?: string;
|
||||
};
|
||||
|
||||
export type Poll = {
|
||||
id: string; // live_chat_poll_id from YouTube
|
||||
id: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
@@ -30,7 +31,7 @@ export type ChatMessage = {
|
||||
authorPhoto?: string;
|
||||
authorChannelId?: string;
|
||||
text: string;
|
||||
runs?: MessageRun[]; // structured runs for emojis
|
||||
runs?: MessageRun[];
|
||||
publishedAt: string;
|
||||
badges?: Badge[];
|
||||
isModerator?: boolean;
|
||||
@@ -39,7 +40,30 @@ export type ChatMessage = {
|
||||
superChat?: SuperChatInfo;
|
||||
membershipGift?: boolean;
|
||||
membershipGiftPurchase?: boolean;
|
||||
membershipLevel?: string; // e.g., "New member", "Member (6 months)", etc.
|
||||
giftCount?: number; // Number of memberships gifted
|
||||
leaderboardRank?: number; // YouTube leaderboard rank (1, 2, 3, etc.)
|
||||
membershipLevel?: string;
|
||||
giftCount?: number;
|
||||
leaderboardRank?: number;
|
||||
};
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'live' | 'reconnecting';
|
||||
|
||||
export type ConnectionStatus = {
|
||||
state: ConnectionState;
|
||||
liveId: string | null;
|
||||
title?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Events pushed on the single `/events` SSE stream.
|
||||
* `init` is sent once per connection with the full current state; the rest are deltas.
|
||||
*/
|
||||
export type ServerEvent =
|
||||
| { type: 'init'; status: ConnectionStatus; messages: ChatMessage[]; selection: ChatMessage | null; poll: Poll | null }
|
||||
| { type: 'message'; message: ChatMessage }
|
||||
| { type: 'selection'; message: ChatMessage | null }
|
||||
| { type: 'poll'; poll: Poll | null }
|
||||
| { type: 'status'; status: ConnectionStatus }
|
||||
| { type: 'clear' };
|
||||
|
||||
export type ServerEventType = ServerEvent['type'];
|
||||
|
||||
Reference in New Issue
Block a user