feat: add YouTube Live chat connection controls with UI

This commit is contained in:
Yusuf İpek
2025-10-05 04:42:47 +03:00
parent 47c9a67362
commit 18d2f78516
7 changed files with 414 additions and 22 deletions
+124
View File
@@ -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() {
</div>
</header>
<ConnectionControl
connected={connected}
liveId={liveId}
connecting={connecting}
onConnect={connect}
onDisconnect={disconnect}
/>
<section className="dashboard__main">
<div className="chatPanel">
<div className="chatPanel__header">
@@ -213,3 +222,118 @@ function useOverlaySelection() {
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 };
}
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 (
<div className="connectionControl">
<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>
);
}
+162 -1
View File
@@ -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 {
+20 -3
View File
@@ -12,13 +12,30 @@ type SelectionPayload = {
export default function OverlayPage() {
const [message, setMessage] = useState<ChatMessage | null>(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 (
<main className="overlay">
{message ? (
<div className="overlay__card">
<div className={`overlay__card ${fadingOut ? 'overlay__card--fadeOut' : ''}`}>
<div className="overlay__header">
{message.authorPhoto && (
<img src={message.authorPhoto} alt={message.author} className="overlay__avatar" />
+3 -1
View File
@@ -2,7 +2,9 @@
const nextConfig = {
reactStrictMode: true,
experimental: {
serverActions: true
serverActions: {
bodySizeLimit: '2mb'
}
}
};