mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-12 03:16:10 +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:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user