diff --git a/backend/src/index.ts b/backend/src/index.ts index 7609078..8822b6f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,10 +8,18 @@ const MAX_MESSAGES = 500; export async function startBackend() { const fastify = Fastify({ - logger: true + logger: { + level: 'info' + } }); - await fastify.register(cors, { origin: true }); + // Register CORS before any routes + await fastify.register(cors, { + origin: '*', + methods: ['GET', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'PATCH'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: false + }); const store: ChatMessage[] = []; let currentSelection: ChatMessage | null = null; @@ -22,6 +30,7 @@ export async function startBackend() { const shouldMock = !parsedLiveId; let ingestion: IngestionContext | null = null; + let mockInterval: NodeJS.Timeout | null = null; if (!shouldMock) { try { @@ -46,16 +55,99 @@ export async function startBackend() { } if (!ingestion) { - seedMockMessages(store, overlayEmitter); + mockInterval = seedMockMessages(store, overlayEmitter); } fastify.get('/health', async () => ({ status: 'ok', messages: store.length, selection: currentSelection?.id ?? null, - mode: ingestion ? 'live' : 'mock' + mode: ingestion ? 'live' : 'mock', + connected: !!ingestion, + liveId: ingestion?.videoId ?? null })); + fastify.post<{ Body: { liveId: string } }>('/chat/connect', async (request, reply) => { + const { liveId } = request.body ?? {}; + 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) => { + console.log(`[Chat] ${message.author}: ${message.text}`); + store.push(message); + if (store.length > MAX_MESSAGES) { + store.splice(0, store.length - MAX_MESSAGES); + } + }); + + 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); + reply.status(500); + return { error: 'Failed to connect to YouTube Live chat' }; + } + }); + + 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); + return { ok: true }; + }); + fastify.get('/chat/messages', async () => ({ messages: store })); @@ -85,15 +177,6 @@ export async function startBackend() { return { ok: true }; }); - fastify.options('/overlay/stream', async (request, reply) => { - reply.headers({ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }); - reply.status(204).send(); - }); - fastify.get('/overlay/stream', async (request, reply) => { reply.hijack(); @@ -158,10 +241,10 @@ function extractLiveId(input: string): string { function seedMockMessages( store: ChatMessage[], overlayEmitter: EventEmitter<{ update: (message: ChatMessage | null) => void }> -) { +): NodeJS.Timeout { let counter = 0; const authors = ['Ada', 'Linus', 'Grace', 'Marge']; - setInterval(() => { + return setInterval(() => { const message: ChatMessage = { id: `mock-${Date.now()}`, author: authors[counter % authors.length], diff --git a/backend/src/ingestion/youtubei.ts b/backend/src/ingestion/youtubei.ts index 6c621bc..c1a2268 100644 --- a/backend/src/ingestion/youtubei.ts +++ b/backend/src/ingestion/youtubei.ts @@ -85,10 +85,15 @@ export async function fetchChatBatch( 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: ctx.liveChat?.continuation?.timeout_ms ?? defaultTimeout + timeoutMs: validTimeout }; } diff --git a/client/app/dashboard/page.tsx b/client/app/dashboard/page.tsx index bc3f353..61061d4 100644 --- a/client/app/dashboard/page.tsx +++ b/client/app/dashboard/page.tsx @@ -9,6 +9,7 @@ const POLL_INTERVAL = 2500; export default function DashboardPage() { const { messages, refresh, error: pollError } = useChatMessages(); const { selection, status: overlayStatus } = useOverlaySelection(); + const { connected, liveId, connect, disconnect, connecting } = useConnection(); const handleSelect = useCallback( async (message: ChatMessage) => { @@ -61,6 +62,14 @@ export default function DashboardPage() { + +
@@ -213,3 +222,118 @@ function useOverlaySelection() { return { selection, status }; } + +function useConnection() { + const [connected, setConnected] = useState(false); + const [liveId, setLiveId] = useState(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 }; +} + +type ConnectionControlProps = { + connected: boolean; + liveId: string | null; + connecting: boolean; + onConnect: (liveId: string) => void; + onDisconnect: () => void; +}; + +function ConnectionControl({ connected, liveId, connecting, onConnect, onDisconnect }: ConnectionControlProps) { + const [inputValue, setInputValue] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (inputValue.trim()) { + onConnect(inputValue.trim()); + } + }; + + return ( +
+
+ {connected ? ( +
+
+ 🟢 Connected + Live ID: {liveId} +
+ +
+ ) : ( +
+
+ + setInputValue(e.target.value)} + placeholder="e.g., dQw4w9WgXcQ or https://youtube.com/watch?v=..." + disabled={connecting} + /> +
+ +
+ )} +
+
+ ); +} diff --git a/client/app/globals.css b/client/app/globals.css index f2cba1a..dde5fdb 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -124,13 +124,147 @@ main { display: flex; flex-direction: column; align-items: center; - padding: 2rem clamp(1rem, 5vw, 3rem); + padding: 0 clamp(1rem, 5vw, 3rem) 2rem; gap: 2rem; max-width: 1400px; width: 100%; margin: 0 auto; } +.connectionControl { + width: 100%; + padding: 1.5rem clamp(1rem, 5vw, 3rem); + background: rgba(30, 41, 59, 0.5); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.connectionControl__content { + max-width: 1400px; + margin: 0 auto; +} + +.connectionControl__connected { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.connectionControl__info { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.connectionControl__badge { + padding: 0.5rem 1rem; + background: rgba(34, 197, 94, 0.2); + color: #4ade80; + border-radius: 999px; + font-weight: 600; + font-size: 0.9rem; +} + +.connectionControl__liveId { + color: #94a3b8; + font-size: 0.9rem; + font-family: 'Monaco', 'Courier New', monospace; +} + +.connectionControl__form { + display: flex; + gap: 1rem; + align-items: flex-end; +} + +.connectionControl__input { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.connectionControl__input label { + color: #94a3b8; + font-size: 0.85rem; + font-weight: 500; +} + +.connectionControl__input input { + padding: 0.75rem 1rem; + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #e2e8f0; + font-size: 0.95rem; + font-family: 'Monaco', 'Courier New', monospace; + transition: border-color 150ms ease; +} + +.connectionControl__input input:focus { + outline: none; + border-color: rgba(96, 165, 250, 0.5); +} + +.connectionControl__input input::placeholder { + color: #64748b; +} + +.connectionControl__input input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-connect, +.btn-disconnect { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 150ms ease; + white-space: nowrap; +} + +.btn-connect { + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + color: #fff; +} + +.btn-connect:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(59, 130, 246, 0.3); +} + +.btn-connect:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-disconnect { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.btn-disconnect:hover { + background: rgba(239, 68, 68, 0.25); + transform: translateY(-1px); +} + +@media (max-width: 768px) { + .connectionControl__form { + flex-direction: column; + align-items: stretch; + } + + .connectionControl__connected { + flex-direction: column; + align-items: flex-start; + } +} + .chatPanel { width: 100%; background: rgba(30, 41, 59, 0.4); @@ -392,6 +526,28 @@ main { background: rgba(0, 0, 0, 0); } +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.95); + } +} + .overlay__card { padding: 2rem 2.5rem; border-radius: 24px; @@ -404,6 +560,11 @@ main { display: flex; flex-direction: column; gap: 1.25rem; + animation: fadeIn 0.4s ease-out; +} + +.overlay__card--fadeOut { + animation: fadeOut 0.3s ease-in forwards; } .overlay__header { diff --git a/client/app/overlay/page.tsx b/client/app/overlay/page.tsx index 69c6118..19b91fb 100644 --- a/client/app/overlay/page.tsx +++ b/client/app/overlay/page.tsx @@ -12,13 +12,30 @@ type SelectionPayload = { export default function OverlayPage() { const [message, setMessage] = useState(null); const [connected, setConnected] = useState(false); + const [fadingOut, setFadingOut] = useState(false); useEffect(() => { const source = new EventSource(`${BACKEND_URL}/overlay/stream`); + const onSelection = (event: MessageEvent) => { try { const payload: SelectionPayload = JSON.parse(event.data); - setMessage(payload.message); + + setMessage((prevMessage) => { + if (payload.message === null && prevMessage !== null) { + // Trigger fade out animation before clearing + setFadingOut(true); + setTimeout(() => { + setMessage(null); + setFadingOut(false); + }, 300); + return prevMessage; // Keep current message during fade + } else { + setFadingOut(false); + return payload.message; + } + }); + setConnected(true); } catch (error) { console.error('overlay: failed to parse payload', error); @@ -33,12 +50,12 @@ export default function OverlayPage() { source.removeEventListener('selection', onSelection as EventListener); source.close(); }; - }, []); + }, []); // Empty dependency array - only connect once return (
{message ? ( -
+
{message.authorPhoto && ( {message.author} diff --git a/client/next.config.js b/client/next.config.js index 420bdd2..95f56c7 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -2,7 +2,9 @@ const nextConfig = { reactStrictMode: true, experimental: { - serverActions: true + serverActions: { + bodySizeLimit: '2mb' + } } }; diff --git a/package.json b/package.json index a73c254..35d053e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packageManager": "pnpm@8.15.4", "scripts": { "dev": "concurrently \"pnpm dev:backend\" \"pnpm dev:client\"", - "dev:backend": "tsx --env-file=.env backend/src/index.ts", + "dev:backend": "tsx backend/src/index.ts", "dev:client": "next dev client", "build": "pnpm build:backend && pnpm build:client", "build:backend": "tsc -p backend/tsconfig.build.json",