mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 19:06:14 +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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user