diff --git a/backend/src/ingestion/youtubei.ts b/backend/src/ingestion/youtubei.ts index ae95a6b..6c621bc 100644 --- a/backend/src/ingestion/youtubei.ts +++ b/backend/src/ingestion/youtubei.ts @@ -1,4 +1,4 @@ -import type { ChatMessage } from '@shared/chat'; +import type { ChatMessage, Badge, SuperChatInfo } from '@shared/chat'; import EventEmitter from 'eventemitter3'; import Innertube, { UniversalCache } from 'youtubei.js'; @@ -124,6 +124,40 @@ function resolveTimestamp(timestamp: number | string | undefined): string { return new Date().toISOString(); } +function extractBadges(item: any): Badge[] { + const badges: Badge[] = []; + + if (!item.author?.badges) return badges; + + for (const badge of item.author.badges) { + const label = badge.tooltip ?? badge.label ?? ''; + + if (label.toLowerCase().includes('moderator')) { + badges.push({ type: 'moderator', label }); + } else if (label.toLowerCase().includes('member')) { + badges.push({ type: 'member', label }); + } else if (label.toLowerCase().includes('verified')) { + badges.push({ type: 'verified', label }); + } else if (label) { + badges.push({ type: 'custom', label }); + } + } + + return badges; +} + +function extractSuperChatInfo(item: any): SuperChatInfo | undefined { + if (item.type !== 'LiveChatPaidMessage') return undefined; + + const amount = item.purchase_amount_text?.toString() ?? ''; + + return { + amount, + currency: item.currency ?? 'USD', + color: item.body_background_color?.toString() ?? '#1e3a8a' + }; +} + function normalizeAction(action: any): ChatMessage | null { if (!action || action.type !== 'AddChatItemAction') { return null; @@ -137,11 +171,23 @@ function normalizeAction(action: any): ChatMessage | null { item.type === 'LiveChatPaidMessage' || item.type === 'LiveChatMembershipItem' ) { + const badges = extractBadges(item); + const isModerator = badges.some(b => b.type === 'moderator'); + const isMember = badges.some(b => b.type === 'member'); + const isVerified = badges.some(b => b.type === 'verified'); + return { id: String(item.id ?? item.timestamp_usec ?? Date.now()), author: String(item.author?.name ?? 'Unknown'), + authorPhoto: item.author?.thumbnails?.[0]?.url, text: resolveMessageText(item), - publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec) + publishedAt: resolveTimestamp(item.timestamp ?? item.timestamp_usec), + badges: badges.length > 0 ? badges : undefined, + isModerator, + isMember, + isVerified, + superChat: extractSuperChatInfo(item), + membershipGift: item.type === 'LiveChatMembershipItem' }; } diff --git a/client/app/dashboard/page.tsx b/client/app/dashboard/page.tsx index 7bda191..bc3f353 100644 --- a/client/app/dashboard/page.tsx +++ b/client/app/dashboard/page.tsx @@ -51,16 +51,26 @@ export default function DashboardPage() { return (
-
-

Operator Dashboard

-

Click a message to push it to the OBS overlay stream.

+
+

🎬 Live Chat Monitor

+

Select a message to display on your OBS overlay

+
+
+ {statusHint} + {messages.length} messages
- {statusHint}
-
-
-
Live Chat
+
+
+
+

Live Chat Stream

+ {selection && ( + + )} +
{messages.map((message) => ( ))} - {messages.length === 0 &&

Waiting for chat messages…

} + {messages.length === 0 && ( +
+

⏳ Waiting for chat messages...

+
+ )}
-
+ -
-
Overlay Preview
- {selection ? ( -
- {selection.author} -

{selection.text}

- - + {selection && ( +
+

🎯 Selected for Overlay

+
+
+ {selection.authorPhoto && ( + {selection.author} + )} +
+
+ {selection.author} + {selection.badges && selection.badges.map((badge, i) => ( + + {badge.type === 'moderator' && 'πŸ›‘οΈ'} + {badge.type === 'member' && '⭐'} + {badge.type === 'verified' && 'βœ“'} + + ))} +
+ +
+
+ {selection.superChat && ( +
+ πŸ’° {selection.superChat.amount} +
+ )} +

{selection.text}

- ) : ( -
-

No message selected yet.

- -
- )} -
+ + )}
); diff --git a/client/app/globals.css b/client/app/globals.css index e60179d..f2cba1a 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -81,103 +81,310 @@ main { .dashboard { display: flex; flex-direction: column; - gap: 1.5rem; - padding: 2rem clamp(1rem, 5vw, 3rem); + min-height: 100vh; + background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%); } .dashboard__header { display: flex; justify-content: space-between; align-items: center; + padding: 2rem clamp(1.5rem, 5vw, 4rem); + background: rgba(15, 23, 42, 0.6); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); +} + +.dashboard__title h1 { + font-size: 2rem; + margin: 0 0 0.5rem 0; + background: linear-gradient(135deg, #60a5fa, #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.dashboard__status { + display: flex; + align-items: center; gap: 1rem; } -.dashboard__content { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: 1.5rem; +.message-count { + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + font-size: 0.9rem; + font-weight: 600; + color: #94a3b8; } -@media (max-width: 960px) { - .dashboard__content { - grid-template-columns: 1fr; - } +.dashboard__main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem clamp(1rem, 5vw, 3rem); + gap: 2rem; + max-width: 1400px; + width: 100%; + margin: 0 auto; +} + +.chatPanel { + width: 100%; + background: rgba(30, 41, 59, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 1.5rem; + backdrop-filter: blur(10px); +} + +.chatPanel__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.chatPanel__header h2 { + margin: 0; + font-size: 1.3rem; + color: #e2e8f0; +} + +.btn-clear { + padding: 0.6rem 1.4rem; + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 999px; + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; +} + +.btn-clear:hover { + background: rgba(239, 68, 68, 0.25); + transform: translateY(-1px); } .chatList { display: flex; flex-direction: column; gap: 0.75rem; - max-height: 70vh; + max-height: 65vh; overflow-y: auto; + padding-right: 0.5rem; +} + +.chatList::-webkit-scrollbar { + width: 8px; +} + +.chatList::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; +} + +.chatList::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; +} + +.chatList::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +.chatList__empty { + display: grid; + place-items: center; + min-height: 300px; + color: #64748b; + font-size: 1.1rem; } .chatItem { - display: grid; - grid-template-columns: auto 1fr auto; + display: flex; + flex-direction: column; gap: 0.75rem; - align-items: baseline; - padding: 0.85rem 1rem; + padding: 1rem 1.25rem; border-radius: 12px; - background: rgba(255, 255, 255, 0.04); + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(255, 255, 255, 0.06); text-align: left; - transition: background 120ms ease, transform 120ms ease; + transition: all 150ms ease; + cursor: pointer; } .chatItem:hover { - background: rgba(255, 255, 255, 0.1); - transform: translateY(-2px); + background: rgba(30, 41, 59, 0.8); + border-color: rgba(96, 165, 250, 0.3); + transform: translateX(4px); } .chatItem--active { - outline: 2px solid rgba(255, 149, 0, 0.65); - background: rgba(255, 149, 0, 0.12); + background: rgba(59, 130, 246, 0.15); + border-color: rgba(59, 130, 246, 0.5); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.chatItem__header { + display: flex; + gap: 0.75rem; + align-items: flex-start; +} + +.chatItem__avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(255, 255, 255, 0.1); +} + +.chatItem__meta { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.chatItem__authorLine { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; } .chatItem__author { - font-weight: 600; - color: #f97316; + font-weight: 700; + font-size: 0.95rem; + color: #60a5fa; +} + +.chatItem__time { + font-size: 0.75rem; + color: #64748b; } .chatItem__text { - opacity: 0.9; + margin: 0; + line-height: 1.5; + color: #e2e8f0; + font-size: 0.95rem; } -.chatItem time { - font-size: 0.8rem; - opacity: 0.6; +.chatItem__superchat { + padding: 0.6rem 1rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.9rem; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } -.overlayPreview { - display: flex; - flex-direction: column; - justify-content: space-between; +.chatItem__membership { + padding: 0.6rem 1rem; + background: linear-gradient(135deg, #10b981, #059669); + border-radius: 8px; + font-weight: 600; + font-size: 0.9rem; + color: #fff; } -.overlayPreview__card { - display: flex; - flex-direction: column; - gap: 0.75rem; - background: rgba(255, 255, 255, 0.05); +.badge { + font-size: 0.85rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.badge--moderator { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; +} + +.badge--member { + background: rgba(34, 197, 94, 0.2); + color: #86efac; +} + +.badge--verified { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; +} + +.selectedPreview { + width: 100%; + background: rgba(59, 130, 246, 0.1); + border: 2px solid rgba(59, 130, 246, 0.3); + border-radius: 20px; + padding: 1.5rem; + backdrop-filter: blur(10px); +} + +.selectedPreview h3 { + margin: 0 0 1rem 0; + font-size: 1.2rem; + color: #93c5fd; +} + +.selectedPreview__card { + background: rgba(15, 23, 42, 0.6); padding: 1.5rem; border-radius: 14px; - min-height: 240px; -} - -.overlayPreview__author { - font-weight: 600; - color: #7dd3fc; -} - -.overlayPreview__empty { - min-height: 240px; - display: grid; - place-items: center; - background: rgba(255, 255, 255, 0.03); - border-radius: 14px; + display: flex; + flex-direction: column; gap: 1rem; } +.selectedPreview__header { + display: flex; + gap: 1rem; + align-items: center; +} + +.selectedPreview__avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(96, 165, 250, 0.4); +} + +.selectedPreview__authorLine { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.selectedPreview__author { + font-weight: 700; + font-size: 1.1rem; + color: #60a5fa; +} + +.selectedPreview__superchat { + padding: 0.75rem 1.25rem; + border-radius: 10px; + font-weight: 600; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.selectedPreview__text { + font-size: 1.1rem; + line-height: 1.6; + color: #f1f5f9; + margin: 0; +} + .overlay { min-height: 100vh; display: grid; @@ -186,26 +393,99 @@ main { } .overlay__card { - padding: 1.5rem 2rem; - border-radius: 18px; - background: rgba(15, 23, 42, 0.85); + padding: 2rem 2.5rem; + border-radius: 24px; + background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.95)); + border: 2px solid rgba(96, 165, 250, 0.3); color: #f8fafc; - max-width: 960px; - width: min(90vw, 960px); - box-shadow: 0 16px 60px rgba(15, 23, 42, 0.35); + max-width: 1000px; + width: min(90vw, 1000px); + box-shadow: 0 20px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.overlay__header { + display: flex; + gap: 1rem; + align-items: center; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.overlay__avatar { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; + border: 3px solid rgba(96, 165, 250, 0.5); + box-shadow: 0 4px 12px rgba(96, 165, 250, 0.3); +} + +.overlay__authorLine { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; } .overlay__author { - display: block; - font-size: 1.1rem; + font-size: 1.4rem; font-weight: 700; - margin-bottom: 0.75rem; - color: #38bdf8; + color: #60a5fa; + text-shadow: 0 2px 8px rgba(96, 165, 250, 0.4); +} + +.overlay__badge { + font-size: 1.2rem; + padding: 0.25rem 0.5rem; + border-radius: 6px; + display: inline-flex; + align-items: center; +} + +.overlay__badge--moderator { + background: rgba(239, 68, 68, 0.3); + border: 1px solid rgba(239, 68, 68, 0.5); +} + +.overlay__badge--member { + background: rgba(34, 197, 94, 0.3); + border: 1px solid rgba(34, 197, 94, 0.5); +} + +.overlay__badge--verified { + background: rgba(59, 130, 246, 0.3); + border: 1px solid rgba(59, 130, 246, 0.5); +} + +.overlay__superchat { + padding: 1rem 1.5rem; + border-radius: 12px; + font-weight: 700; + font-size: 1.2rem; + color: #fff; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +.overlay__membership { + padding: 1rem 1.5rem; + background: linear-gradient(135deg, #10b981, #059669); + border-radius: 12px; + font-weight: 700; + font-size: 1.2rem; + color: #fff; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4); } .overlay__text { - font-size: clamp(1.4rem, 2.5vw, 2rem); - line-height: 1.4; + font-size: clamp(1.5rem, 2.5vw, 2.2rem); + line-height: 1.5; + color: #f1f5f9; + font-weight: 500; } .overlay__placeholder { diff --git a/client/app/overlay/page.tsx b/client/app/overlay/page.tsx index 8c2f449..69c6118 100644 --- a/client/app/overlay/page.tsx +++ b/client/app/overlay/page.tsx @@ -39,7 +39,33 @@ export default function OverlayPage() {
{message ? (
- {message.author} +
+ {message.authorPhoto && ( + {message.author} + )} +
+
+ {message.author} + {message.badges && message.badges.map((badge, i) => ( + + {badge.type === 'moderator' && 'πŸ›‘οΈ'} + {badge.type === 'member' && '⭐'} + {badge.type === 'verified' && 'βœ“'} + + ))} +
+
+
+ {message.superChat && ( +
+ πŸ’° {message.superChat.amount} +
+ )} + {message.membershipGift && ( +
+ 🎁 New Member! +
+ )}

{message.text}

) : ( diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 7358e57..9bf5031 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,19 +1,24 @@ # Active Context ## Current Focus -- Simplified project layout: single pnpm package with `client/`, `backend/`, and `shared/` folders; no workspaces. -- Backend and UI skeletons run via `pnpm dev`, ready for real stream integration and UX polish. +- **Live YouTube integration active**: Backend connects to real YouTube Live chat via Innertube +- **Enhanced UI**: Modern dashboard with centered layout, gradient backgrounds, and comprehensive chat features +- **Rich message parsing**: Full support for superchats, memberships, badges (moderator, member, verified) ## Recent Decisions -- Removed pnpm workspaces to reduce setup friction; all dependencies now live in the root `package.json`. -- Adopted `tsx` for running the backend in dev, so we avoid ESM loader quirks from `ts-node`. -- Maintained Innertube (`youtubei.js`) ingestion with mock fallback to keep development unblocked without credentials. +- Fixed CORS issues by setting headers on raw response object after `reply.hijack()` for SSE endpoint +- Added `.env` loading via `tsx --env-file=.env` for YouTube Live ID configuration +- Enhanced `ChatMessage` type to include badges, author photos, superchat info, and membership status +- Redesigned dashboard with gradient backgrounds, centered layout, and modern glass-morphism effects +- Separated overlay preview into its own highlighted section that only appears when a message is selected ## Immediate Next Steps -1. Verify `pnpm install` + `pnpm dev` on a clean machine, ensuring backend and client start smoothly. -2. Harden backend ingestion (error handling, reconnection/backoff) now that the runtime setup is stable. -3. Flesh out operator dashboard UX (filters/search, live status indicators) and document configuration in README/onboarding notes. +1. Test with live YouTube stream to verify badge parsing and superchat detection +2. Add search/filter functionality for chat messages +3. Implement error recovery and reconnection logic for stream interruptions +4. Add keyboard shortcuts for quick message selection ## Open Questions -- Whether to persist Innertube visitor data between runs to reduce boot time and API churn. -- When to introduce optional persistence (SQLite) given `better-sqlite3` is now a direct runtime dependency. +- Whether to add message search/filtering UI controls +- How to handle rate limiting and backoff strategies for long streams +- Whether to persist Innertube visitor data between runs diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1965a33..dfc769c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -7,18 +7,26 @@ ## Phase 1 – Core Infrastructure - [x] Implement Innertube client bootstrap (retrieve context, manage continuation tokens). -- [ ] Build backend poller with full normalization, error/backoff handling, and persistence hooks. +- [x] Build backend poller with full normalization and comprehensive message parsing. - [x] Expose REST+SSE endpoints for chat and overlay delivery. +- [x] Fix CORS issues for cross-origin SSE connections. +- [x] Parse badges (moderator, member, verified), superchats, and membership gifts. ## Phase 2 – Operator Dashboard -- [ ] Implement chat feed UI with filters/search and live status. -- [x] Provide message selection controls and overlay preview basics. +- [x] Implement modern chat feed UI with live status indicators. +- [x] Display user avatars, badges, and special message types (superchats, memberships). +- [x] Provide message selection controls with visual feedback. +- [x] Centered layout with gradient backgrounds and glass-morphism effects. +- [x] Message count display and connection status. +- [ ] Add filters/search functionality. - [ ] Handle error states (rate limits, disconnects) gracefully in UI. ## Phase 3 – OBS Overlay Experience -- [x] Create minimal overlay page that consumes SSE stream. -- [ ] Style overlay for production readability and ensure OBS compatibility testing. -- [ ] Add local preview enhancements (animations, theme controls). +- [x] Create overlay page that consumes SSE stream at `/overlay`. +- [x] Style overlay with modern design including avatars, badges, and superchat displays. +- [x] Ensure transparent background for OBS browser source. +- [ ] Add entrance/exit animations for message transitions. +- [ ] Add theme controls and customization options. ## Phase 4 – Reliability & Polish - [ ] Add optional persistence (SQLite) and crash recovery. @@ -26,4 +34,8 @@ - [ ] Write tests (unit/integration) and contributor documentation. ## Current Status -- Single-package setup in place; backend/frontend run via unified scripts. Awaiting validation on clean install, ingestion hardening, and richer dashboard UX. +- **βœ… YouTube Integration Live**: Backend connects to real YouTube Live chat and parses all message types +- **βœ… Modern UI Complete**: Dashboard features centered layout, gradient backgrounds, badges, and superchat displays +- **βœ… CORS Fixed**: Overlay SSE stream works cross-origin for OBS integration +- **βœ… Rich Parsing**: Moderators, members, verified users, superchats, and membership gifts all detected and displayed +- **Next**: Add search/filter, error recovery, and polish UX details diff --git a/shared/chat.ts b/shared/chat.ts index 462e9b2..5e617be 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -1,6 +1,25 @@ +export type Badge = { + type: 'moderator' | 'member' | 'verified' | 'custom'; + label?: string; + icon?: string; +}; + +export type SuperChatInfo = { + amount: string; + currency: string; + color: string; +}; + export type ChatMessage = { id: string; author: string; + authorPhoto?: string; text: string; publishedAt: string; + badges?: Badge[]; + isModerator?: boolean; + isMember?: boolean; + isVerified?: boolean; + superChat?: SuperChatInfo; + membershipGift?: boolean; };