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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user