mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
feat: add YouTube live poll support
Add backend support for YouTube live polls including: - Poll event handling and state tracking - New `/poll/current` endpoint for current poll data - Server-sent events stream at `/poll/stream` for real-time updates - Poll emitter for broadcasting poll changes to clients
This commit is contained in:
+51
-3
@@ -1,7 +1,7 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import EventEmitter from 'eventemitter3';
|
import EventEmitter from 'eventemitter3';
|
||||||
import type { ChatMessage } from '@shared/chat';
|
import type { ChatMessage, Poll } from '@shared/chat';
|
||||||
import { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei';
|
import { bootstrapInnertube, type IngestionContext } from './ingestion/youtubei';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
@@ -30,7 +30,9 @@ export async function startBackend() {
|
|||||||
|
|
||||||
const store: ChatMessage[] = [];
|
const store: ChatMessage[] = [];
|
||||||
let currentSelection: ChatMessage | null = null;
|
let currentSelection: ChatMessage | null = null;
|
||||||
|
let currentPoll: Poll | null = null;
|
||||||
const overlayEmitter = new EventEmitter<{ update: (message: ChatMessage | null) => void }>();
|
const overlayEmitter = new EventEmitter<{ update: (message: ChatMessage | null) => void }>();
|
||||||
|
const pollEmitter = new EventEmitter<{ update: (poll: Poll | null) => void }>();
|
||||||
|
|
||||||
const rawLiveId = process.env.YOUTUBE_LIVE_ID ?? '';
|
const rawLiveId = process.env.YOUTUBE_LIVE_ID ?? '';
|
||||||
const parsedLiveId = extractLiveId(rawLiveId);
|
const parsedLiveId = extractLiveId(rawLiveId);
|
||||||
@@ -50,6 +52,10 @@ export async function startBackend() {
|
|||||||
// This ensures we don't wait until hitting MAX_MESSAGES
|
// This ensures we don't wait until hitting MAX_MESSAGES
|
||||||
trimMessages(store);
|
trimMessages(store);
|
||||||
});
|
});
|
||||||
|
ingestion.emitter.on('poll', (poll) => {
|
||||||
|
currentPoll = poll;
|
||||||
|
pollEmitter.emit('update', poll);
|
||||||
|
});
|
||||||
ingestion.emitter.on('error', (error) => {
|
ingestion.emitter.on('error', (error) => {
|
||||||
console.error('[Backend] Innertube ingestion error:', error);
|
console.error('[Backend] Innertube ingestion error:', error);
|
||||||
});
|
});
|
||||||
@@ -110,14 +116,19 @@ export async function startBackend() {
|
|||||||
console.log(`[Backend] Connecting to YouTube Live ID: ${parsedLiveId}`);
|
console.log(`[Backend] Connecting to YouTube Live ID: ${parsedLiveId}`);
|
||||||
ingestion = await bootstrapInnertube(parsedLiveId);
|
ingestion = await bootstrapInnertube(parsedLiveId);
|
||||||
console.log(`[Backend] ✓ YouTube chat connected successfully`);
|
console.log(`[Backend] ✓ YouTube chat connected successfully`);
|
||||||
|
|
||||||
ingestion.emitter.on('message', (message) => {
|
ingestion.emitter.on('message', (message) => {
|
||||||
store.push(message);
|
store.push(message);
|
||||||
// Trim regularly to keep regular messages under control
|
// Trim regularly to keep regular messages under control
|
||||||
// This ensures we don't wait until hitting MAX_MESSAGES
|
// This ensures we don't wait until hitting MAX_MESSAGES
|
||||||
trimMessages(store);
|
trimMessages(store);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ingestion.emitter.on('poll', (poll) => {
|
||||||
|
currentPoll = poll;
|
||||||
|
pollEmitter.emit('update', poll);
|
||||||
|
});
|
||||||
|
|
||||||
ingestion.emitter.on('error', (error) => {
|
ingestion.emitter.on('error', (error) => {
|
||||||
console.error('[Backend] Innertube ingestion error:', error);
|
console.error('[Backend] Innertube ingestion error:', error);
|
||||||
});
|
});
|
||||||
@@ -157,6 +168,10 @@ export async function startBackend() {
|
|||||||
messages: store
|
messages: store
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
fastify.get('/poll/current', async () => ({
|
||||||
|
poll: currentPoll
|
||||||
|
}));
|
||||||
|
|
||||||
fastify.post<{ Body: { id?: string } }>('/overlay/selection', async (request, reply) => {
|
fastify.post<{ Body: { id?: string } }>('/overlay/selection', async (request, reply) => {
|
||||||
const { id } = request.body ?? {};
|
const { id } = request.body ?? {};
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -182,6 +197,39 @@ export async function startBackend() {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.get('/poll/stream', async (request, reply) => {
|
||||||
|
reply.hijack();
|
||||||
|
|
||||||
|
const res = reply.raw;
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
res.writeHead(200);
|
||||||
|
res.write(': connected\n\n');
|
||||||
|
|
||||||
|
const send = (poll: Poll | null) => {
|
||||||
|
res.write(`event: poll\ndata: ${JSON.stringify({ poll })}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
res.write('event: heartbeat\ndata: {}\n\n');
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
pollEmitter.on('update', send);
|
||||||
|
|
||||||
|
if (currentPoll) {
|
||||||
|
send(currentPoll);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.raw.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
pollEmitter.off('update', send);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
fastify.get('/overlay/stream', async (request, reply) => {
|
fastify.get('/overlay/stream', async (request, reply) => {
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ChatMessage, Badge, SuperChatInfo } from '@shared/chat';
|
import type { ChatMessage, Badge, SuperChatInfo, Poll } from '@shared/chat';
|
||||||
import EventEmitter from 'eventemitter3';
|
import EventEmitter from 'eventemitter3';
|
||||||
import Innertube, { UniversalCache } from 'youtubei.js';
|
import Innertube, { UniversalCache } from 'youtubei.js';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export type ContinuationState = {
|
|||||||
|
|
||||||
export type ChatEventEmitter = EventEmitter<{
|
export type ChatEventEmitter = EventEmitter<{
|
||||||
message: (message: ChatMessage) => void;
|
message: (message: ChatMessage) => void;
|
||||||
|
poll: (poll: Poll) => void;
|
||||||
error: (error: unknown) => void;
|
error: (error: unknown) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -64,11 +65,21 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
|
|||||||
const emitter: ChatEventEmitter = new EventEmitter();
|
const emitter: ChatEventEmitter = new EventEmitter();
|
||||||
|
|
||||||
liveChat.on('chat-update', (action: any) => {
|
liveChat.on('chat-update', (action: any) => {
|
||||||
// Log the complete raw action data from YouTube (commented out for production)
|
// Handle poll updates (just detect active/closed state)
|
||||||
//console.log('=== YOUTUBE RAW MESSAGE DATA ===');
|
if (action?.type === 'UpdateLiveChatPollAction') {
|
||||||
//console.log('Action type:', action?.type);
|
const pollId = action?.poll_to_update?.live_chat_poll_id;
|
||||||
//console.log('Complete action object:', JSON.stringify(action, null, 2));
|
if (pollId) {
|
||||||
|
emitter.emit('poll', { id: String(pollId), active: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle poll closing
|
||||||
|
if (action?.type === 'CloseLiveChatActionPanelAction' || action?.type === 'RemoveBannerForLiveChatCommand') {
|
||||||
|
emitter.emit('poll', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const normalized = normalizeAction(action);
|
const normalized = normalizeAction(action);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
emitter.emit('message', normalized);
|
emitter.emit('message', normalized);
|
||||||
@@ -77,8 +88,11 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
|
|||||||
|
|
||||||
liveChat.on('error', (err: unknown) => {
|
liveChat.on('error', (err: unknown) => {
|
||||||
const msg = (err as any)?.message || String(err);
|
const msg = (err as any)?.message || String(err);
|
||||||
if (msg && msg.includes('LiveChatReportModerationStateCommand not found')) {
|
// Ignore known non-fatal parser drift issues that YouTube.js auto-generates
|
||||||
console.warn('[Ingestion] Non-fatal parser drift (ignored):', msg);
|
if (msg && (
|
||||||
|
msg.includes('LiveChatReportModerationStateCommand not found') ||
|
||||||
|
msg.includes('CloseLiveChatActionPanelAction not found')
|
||||||
|
)) {
|
||||||
return; // ignore noisy parser drift that YouTube.js JITs around
|
return; // ignore noisy parser drift that YouTube.js JITs around
|
||||||
}
|
}
|
||||||
console.error('[Ingestion] Live chat error:', err);
|
console.error('[Ingestion] Live chat error:', err);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import type { ChatMessage } from '@shared/chat';
|
import type { ChatMessage, Poll } from '@shared/chat';
|
||||||
import { useTimezone } from '../../lib/TimezoneContext';
|
import { useTimezone } from '../../lib/TimezoneContext';
|
||||||
import { formatTimestamp } from '../../lib/timezone';
|
import { formatTimestamp } from '../../lib/timezone';
|
||||||
import { proxyImageUrl } from '../../lib/imageProxy';
|
import { proxyImageUrl } from '../../lib/imageProxy';
|
||||||
@@ -46,6 +46,7 @@ let globalConnectionListeners: Set<(payload: any) => void> = new Set();
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { messages, refresh, error: pollError } = useChatMessages();
|
const { messages, refresh, error: pollError } = useChatMessages();
|
||||||
const { selection, status: overlayStatus } = useOverlaySelection();
|
const { selection, status: overlayStatus } = useOverlaySelection();
|
||||||
|
const { poll: currentPoll } = usePoll();
|
||||||
const { connected, liveId, connect, disconnect, connecting } = useConnection();
|
const { connected, liveId, connect, disconnect, connecting } = useConnection();
|
||||||
const [confirmUrl, setConfirmUrl] = useState<string | null>(null);
|
const [confirmUrl, setConfirmUrl] = useState<string | null>(null);
|
||||||
const [superChatPulse, setSuperChatPulse] = useState(false);
|
const [superChatPulse, setSuperChatPulse] = useState(false);
|
||||||
@@ -198,6 +199,12 @@ export default function DashboardPage() {
|
|||||||
{connected && (
|
{connected && (
|
||||||
<>
|
<>
|
||||||
<header className="dashboard__tabs">
|
<header className="dashboard__tabs">
|
||||||
|
{currentPoll && (
|
||||||
|
<div className="poll-indicator" title="Active poll on YouTube">
|
||||||
|
<span className="poll-indicator__icon">📊</span>
|
||||||
|
<span className="poll-indicator__text">Active Poll</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="tab">
|
<div className="tab">
|
||||||
Messages <span className="tab__count">{regularMessages.length}</span>
|
Messages <span className="tab__count">{regularMessages.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,6 +320,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -455,6 +463,38 @@ function useConnection() {
|
|||||||
return { connected, liveId, connect, disconnect, connecting };
|
return { connected, liveId, connect, disconnect, connecting };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePoll() {
|
||||||
|
const [poll, setPoll] = useState<Poll | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Create SSE connection for polls
|
||||||
|
const eventSource = new EventSource(`${BACKEND_URL}/poll/stream`);
|
||||||
|
|
||||||
|
eventSource.addEventListener('poll', ((event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data);
|
||||||
|
setPoll(payload.poll);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse poll payload', error);
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
eventSource.addEventListener('heartbeat', () => {
|
||||||
|
// Heartbeat - connection is alive
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('[Dashboard] Poll SSE connection error', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { poll };
|
||||||
|
}
|
||||||
|
|
||||||
type ConnectionControlProps = {
|
type ConnectionControlProps = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
liveId: string | null;
|
liveId: string | null;
|
||||||
|
|||||||
@@ -1357,3 +1357,36 @@ main {
|
|||||||
background: rgba(248, 113, 113, 0.2);
|
background: rgba(248, 113, 113, 0.2);
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Poll indicator */
|
||||||
|
.poll-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c4b5fd;
|
||||||
|
animation: pollPulse 2s ease-in-out infinite;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-indicator__icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-indicator__text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pollPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 rgba(139, 92, 246, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(139, 92, 246, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
- **Smart Message Preservation**: Implemented intelligent message trimming - regular chat messages limited to 200, but superchats and memberships preserved for entire session. Trimming happens on every message to prevent loss of special messages.
|
- **Smart Message Preservation**: Implemented intelligent message trimming - regular chat messages limited to 200, but superchats and memberships preserved for entire session. Trimming happens on every message to prevent loss of special messages.
|
||||||
- **Super Sticker Support**: Added full support for super sticker image display. Backend parser extracts sticker URL and accessibility label from `item.sticker` array in YouTube data. Added `stickerUrl` and `stickerAlt` fields to `SuperChatInfo` type. Image proxy whitelist updated to include `lh3.googleusercontent.com` domain. Dashboard and overlay both render super stickers with 144x144px max dimensions, centered layout, and graceful error handling that hides broken images. Protocol-relative URLs (`//domain.com`) are automatically converted to HTTPS.
|
- **Super Sticker Support**: Added full support for super sticker image display. Backend parser extracts sticker URL and accessibility label from `item.sticker` array in YouTube data. Added `stickerUrl` and `stickerAlt` fields to `SuperChatInfo` type. Image proxy whitelist updated to include `lh3.googleusercontent.com` domain. Dashboard and overlay both render super stickers with 144x144px max dimensions, centered layout, and graceful error handling that hides broken images. Protocol-relative URLs (`//domain.com`) are automatically converted to HTTPS.
|
||||||
- **Leaderboard Badge Support**: Implemented YouTube leaderboard rank display (Top Chatter feature). Backend parser extracts rank from `before_content_buttons` array with CROWN icon, parsing rank number from title field (e.g., "#3"). Added `leaderboardRank` field to `ChatMessage` type. Dashboard and overlay render leaderboard badge with crown emoji (👑) and rank number, styled with golden background (rgba(251, 191, 36, 0.2)) and yellow text (#fcd34d) for prominence.
|
- **Leaderboard Badge Support**: Implemented YouTube leaderboard rank display (Top Chatter feature). Backend parser extracts rank from `before_content_buttons` array with CROWN icon, parsing rank number from title field (e.g., "#3"). Added `leaderboardRank` field to `ChatMessage` type. Dashboard and overlay render leaderboard badge with crown emoji (👑) and rank number, styled with golden background (rgba(251, 191, 36, 0.2)) and yellow text (#fcd34d) for prominence.
|
||||||
- **Task Master AI Integration**: Initialized Task Master AI with OpenRouter's x-ai/grok-code-fast-1 (main), google/gemini-2.5-pro (research), and google/gemini-2.5-flash (fallback) models. Successfully completed Task 2 (Super Sticker Display) and Task 3 (Leaderboard Badge) with all subtasks.
|
- **Task Master AI Integration**: Initialized Task Master AI with OpenRouter's x-ai/grok-code-fast-1 (main), google/gemini-2.5-pro (research), and google/gemini-2.5-flash (fallback) models. Successfully completed Task 2 (Super Sticker Display), Task 3 (Leaderboard Badge), and Task 4 (Live Poll Indicator).
|
||||||
|
- **Live Poll Indicator**: Added simple poll detection that shows a pulsing "📊 Active Poll" indicator in the dashboard header when a YouTube poll is active. Backend listens for `UpdateLiveChatPollAction` and `CloseLiveChatActionPanelAction`/`RemoveBannerForLiveChatCommand` events. Indicator automatically appears/disappears based on poll state. Note: YouTube's API does not provide live vote percentages, so the feature only indicates poll presence without showing results.
|
||||||
|
|
||||||
## Immediate Next Steps
|
## Immediate Next Steps
|
||||||
1. Add live poll display functionality (Task 4)
|
1. Implement user authentication via YouTube OAuth 2.0 (Task 5)
|
||||||
2. Implement user authentication via YouTube OAuth 2.0 (Task 5)
|
2. Add persistent image cache with SQLite (Task 6)
|
||||||
3. Add persistent image cache with SQLite (Task 6)
|
3. Develop overlay theme controls (Task 7)
|
||||||
4. Consider persistent cache for images (SQLite or file-based) for better reliability
|
4. Add error recovery and reconnection logic for stream interruptions
|
||||||
5. Add error recovery and reconnection logic for stream interruptions
|
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
- Whether to add message search/filtering UI controls
|
- Whether to add message search/filtering UI controls
|
||||||
|
|||||||
@@ -23,8 +23,7 @@
|
|||||||
- [x] Visual selection state management (active, normal, previously-selected).
|
- [x] Visual selection state management (active, normal, previously-selected).
|
||||||
- [x] Fix super sticker image not showing.
|
- [x] Fix super sticker image not showing.
|
||||||
- [x] Add chat leaderboard badge.
|
- [x] Add chat leaderboard badge.
|
||||||
- [ ] Show open polls.
|
- [x] Show active poll indicator.
|
||||||
- [ ] Handle error states (rate limits, disconnects) gracefully in UI.
|
|
||||||
|
|
||||||
## Phase 3 – OBS Overlay Experience
|
## Phase 3 – OBS Overlay Experience
|
||||||
- [x] Create overlay page that consumes SSE stream at `/overlay`.
|
- [x] Create overlay page that consumes SSE stream at `/overlay`.
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
- [x] Optimize overlay spacing for efficient use (30-40% more compact).
|
- [x] Optimize overlay spacing for efficient use (30-40% more compact).
|
||||||
- [x] Remove timestamps from overlay for cleaner display.
|
- [x] Remove timestamps from overlay for cleaner display.
|
||||||
- [x] Add smooth fade transitions when switching between messages.
|
- [x] Add smooth fade transitions when switching between messages.
|
||||||
- [ ] Add theme controls and customization options.
|
|
||||||
|
|
||||||
## Phase 4 – Reliability & Polish
|
## Phase 4 – Reliability & Polish
|
||||||
- [x] Implement image proxy with caching to prevent YouTube CDN 429 errors.
|
- [x] Implement image proxy with caching to prevent YouTube CDN 429 errors.
|
||||||
@@ -45,6 +43,9 @@
|
|||||||
- [ ] Ensure the deployment.
|
- [ ] Ensure the deployment.
|
||||||
- [ ] Write tests (unit/integration) and contributor documentation.
|
- [ ] Write tests (unit/integration) and contributor documentation.
|
||||||
|
|
||||||
|
## Phase 5 - Customization
|
||||||
|
- [ ] Add customization options
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
- **✅ YouTube Integration Live**: Backend connects to real YouTube Live chat and parses all message types
|
- **✅ 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
|
- **✅ Modern UI Complete**: Dashboard features centered layout, gradient backgrounds, badges, and superchat displays
|
||||||
@@ -60,4 +61,5 @@
|
|||||||
- **✅ Smart Storage**: Regular messages limited to 200, superchats and memberships preserved for entire session
|
- **✅ Smart Storage**: Regular messages limited to 200, superchats and memberships preserved for entire session
|
||||||
- **✅ Super Sticker Support**: Super sticker images now display correctly in both dashboard and overlay with proper error handling and accessibility labels
|
- **✅ Super Sticker Support**: Super sticker images now display correctly in both dashboard and overlay with proper error handling and accessibility labels
|
||||||
- **✅ Leaderboard Badge Support**: YouTube leaderboard ranks (Top Chatter) now display with crown emoji and rank number next to usernames in both dashboard and overlay with golden styling
|
- **✅ Leaderboard Badge Support**: YouTube leaderboard ranks (Top Chatter) now display with crown emoji and rank number next to usernames in both dashboard and overlay with golden styling
|
||||||
- **Next**: Show open polls, implement error recovery, add user authentication
|
- **✅ Live Poll Indicator**: Pulsing poll indicator shows in dashboard header when a YouTube poll is active, automatically hides when closed
|
||||||
|
- **Next**: Implement user authentication, add persistent image cache, develop overlay theme controls
|
||||||
|
|||||||
@@ -23,3 +23,4 @@
|
|||||||
- **Overlay Animation System**: Two-state approach with `message` (backend data) and `displayMessage` (UI state) enables smooth fade transitions when switching messages. 300ms fade-out followed by content swap and automatic fade-in.
|
- **Overlay Animation System**: Two-state approach with `message` (backend data) and `displayMessage` (UI state) enables smooth fade transitions when switching messages. 300ms fade-out followed by content swap and automatic fade-in.
|
||||||
- **Super Sticker Parsing**: Backend `extractSuperChatInfo()` function detects super stickers via `item.sticker` array. Extracts largest image (144x144px) from sticker thumbnails array, handles protocol-relative URLs by converting to HTTPS, and captures accessibility labels for screen readers. Frontend conditionally renders stickers with error handling that hides broken images via `onError` handler.
|
- **Super Sticker Parsing**: Backend `extractSuperChatInfo()` function detects super stickers via `item.sticker` array. Extracts largest image (144x144px) from sticker thumbnails array, handles protocol-relative URLs by converting to HTTPS, and captures accessibility labels for screen readers. Frontend conditionally renders stickers with error handling that hides broken images via `onError` handler.
|
||||||
- **Leaderboard Badge Parsing**: Backend `extractLeaderboardRank()` function scans `before_content_buttons` array for CROWN icon entries. Extracts rank number from title field using regex pattern `/#(\d+)/`. Returns numeric rank or undefined if not present. Frontend displays badge with crown emoji and rank using golden color scheme for visual prominence.
|
- **Leaderboard Badge Parsing**: Backend `extractLeaderboardRank()` function scans `before_content_buttons` array for CROWN icon entries. Extracts rank number from title field using regex pattern `/#(\d+)/`. Returns numeric rank or undefined if not present. Frontend displays badge with crown emoji and rank using golden color scheme for visual prominence.
|
||||||
|
- **Poll Detection System**: Backend listens for `UpdateLiveChatPollAction` events to detect active polls and emits simple poll state (id + active flag) via SSE. Frontend displays pulsing purple poll indicator in dashboard header when poll is active. Poll closes automatically when backend receives `CloseLiveChatActionPanelAction` or `RemoveBannerForLiveChatCommand` events. Note: YouTube's live chat API does not provide vote percentages even with authentication, so only poll presence is indicated.
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export type MessageRun = {
|
|||||||
emojiAlt?: string;
|
emojiAlt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Poll = {
|
||||||
|
id: string; // live_chat_poll_id from YouTube
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatMessage = {
|
export type ChatMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user