Commit current project

This commit is contained in:
2026-03-29 22:44:13 +02:00
parent b3bccb2ae3
commit 7f9469c07d
77 changed files with 20495 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
import React, { useState } from 'react';
interface TwitchChannel {
id: string;
name: string;
displayName: string;
title: string;
profileImageUrl?: string;
}
interface ChannelSelectorProps {
channels: TwitchChannel[];
selectedChannel: string;
onChannelChange: (channelId: string) => void;
platform: 'twitch' | 'youtube';
}
export default function ChannelSelector({
channels,
selectedChannel,
onChannelChange,
platform,
}: ChannelSelectorProps) {
const [manualChannel, setManualChannel] = useState('');
if (channels.length === 0) return null;
const styles = {
container: {
padding: '8px',
background: '#1a1a1a',
boxShadow: 'none',
},
label: {
fontSize: '0.75rem',
fontWeight: '600',
color: '#94a3b8',
marginBottom: '8px',
display: 'block',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
},
listContainer: {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '8px',
marginBottom: '12px',
},
channelItem: (isSelected: boolean) => ({
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
cursor: 'pointer',
padding: '6px',
borderRadius: '8px',
border: isSelected ? '2px solid #3b82f6' : '2px solid transparent',
backgroundColor: isSelected ? '#1e3a5f' : 'transparent',
transition: 'all 0.2s ease',
flex: '0 0 auto',
}),
profileImage: {
width: '48px',
height: '48px',
borderRadius: '50%',
objectFit: 'cover' as const,
marginBottom: '4px',
border: '2px solid #333333',
},
channelName: {
fontSize: '0.75rem',
color: '#e2e8f0',
textAlign: 'center' as const,
maxWidth: '60px',
overflow: 'hidden' as const,
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
},
};
const handleManualJoin = (e: React.FormEvent) => {
e.preventDefault();
if (manualChannel.trim()) {
onChannelChange(manualChannel.trim().toLowerCase());
setManualChannel('');
}
};
return (
<div style={styles.container as React.CSSProperties}>
<label style={styles.label}>
{platform === 'twitch' ? '📺 Channels' : '▶ Streams'}
</label>
<div style={styles.listContainer as React.CSSProperties}>
{channels.map((channel) => (
<div
key={channel.id}
style={styles.channelItem(selectedChannel === channel.id) as React.CSSProperties}
onClick={() => onChannelChange(channel.id)}
title={channel.displayName}
>
{channel.profileImageUrl && (
<img
src={channel.profileImageUrl}
alt={channel.displayName}
style={styles.profileImage as React.CSSProperties}
/>
)}
<span style={styles.channelName as React.CSSProperties}>
{channel.displayName}
</span>
</div>
))}
</div>
<div style={{ marginTop: '12px' }}>
<label style={styles.label}>Or join manually</label>
<form onSubmit={handleManualJoin} style={{ display: 'flex', gap: '3px' }}>
<input
type="text"
value={manualChannel}
onChange={(e) => setManualChannel(e.target.value)}
placeholder="Enter channel name"
style={{
flex: 1,
padding: '6px',
border: '1px solid #333333',
fontSize: '0.85rem',
backgroundColor: '#0a0a0a',
color: '#e2e8f0',
minWidth: '0',
}}
/>
<button
type="submit"
disabled={!manualChannel.trim()}
style={{
padding: '6px 8px',
border: 'none',
fontSize: '0.85rem',
cursor: manualChannel.trim() ? 'pointer' : 'not-allowed',
backgroundColor: manualChannel.trim() ? '#555555' : '#333333',
color: '#e2e8f0',
flexShrink: 0,
}}
>
Join
</button>
</form>
</div>
</div>
);
}

1024
src/components/ChatApp.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,519 @@
import React, { useState, useEffect, useMemo, memo } from 'react';
import { MdStars, MdAutorenew, MdCardGiftcard, MdRefresh, MdReply, MdContentCopy, MdRedeem } from 'react-icons/md';
import { GiHeartBeats } from 'react-icons/gi';
import { FaUsers } from 'react-icons/fa';
import type { ChatMessage, Platform } from '../lib/types';
import { parseMessageWithEmotes } from '../lib/emotes';
interface ChatDisplayProps {
messages: ChatMessage[];
isLoading: boolean;
selectedPlatforms: Platform[];
onReply?: (message: ChatMessage) => void;
channelName?: string;
channelDisplayName?: string;
streamTitle?: string;
isStreamLive?: boolean;
streamUptime?: string;
viewerCount?: number | null;
onRefreshEmotes?: () => void;
isRefreshingEmotes?: boolean;
onToggleSidebar?: () => void;
sidebarVisible?: boolean;
onCopyMessage?: () => void;
showStreamEmbed?: boolean;
onToggleStreamEmbed?: () => void;
currentUserDisplayName?: string;
fontSize?: number;
/** When provided (unified chat mode), renders this instead of the normal messages area */
unifiedChatSlot?: React.ReactNode;
}
function ChatDisplay({
messages,
isLoading,
selectedPlatforms,
onReply,
channelName,
channelDisplayName,
streamTitle,
isStreamLive,
streamUptime,
viewerCount,
onRefreshEmotes,
isRefreshingEmotes = false,
onToggleSidebar,
sidebarVisible = true,
onCopyMessage,
showStreamEmbed = true,
onToggleStreamEmbed,
currentUserDisplayName,
fontSize = 14,
unifiedChatSlot,
}: ChatDisplayProps) {
const [filteredMessages, setFilteredMessages] = useState<ChatMessage[]>([]);
const [isAtBottom, setIsAtBottom] = useState(true);
const [newMessagesCount, setNewMessagesCount] = useState(0);
const [previousMessageCount, setPreviousMessageCount] = useState(0);
const messagesContainerRef = React.useRef<HTMLDivElement>(null);
const authorColorById = useMemo(() => {
const map = new Map<string, string>();
messages.forEach((message) => {
if (message.authorColor) {
map.set(message.id, message.authorColor);
}
});
return map;
}, [messages]);
useEffect(() => {
let filtered = messages;
// Remove optimistic placeholders when a matching real server message exists.
filtered = filtered.filter((msg, _, arr) => {
if (!msg.optimistic) return true;
// If there's a non-optimistic message with same content, reply target,
// and userId, prefer the real one and drop this optimistic placeholder.
const hasReal = arr.some((other) =>
other !== msg &&
!other.optimistic &&
other.isCurrentUser &&
other.userId === msg.userId &&
other.content === msg.content &&
((other.replyTo?.id || '') === (msg.replyTo?.id || ''))
);
return !hasReal;
});
if (selectedPlatforms.length === 0) {
setFilteredMessages(filtered);
} else {
filtered = filtered.filter((msg) => selectedPlatforms.includes(msg.platform));
setFilteredMessages(filtered);
}
// Update new messages count when not at bottom
if (!isAtBottom) {
const newCount = filtered.length - previousMessageCount;
if (newCount > 0) {
setNewMessagesCount(newCount);
}
}
}, [messages, selectedPlatforms, isAtBottom, previousMessageCount]);
// Auto-scroll to bottom when messages change (only if user is at bottom)
useEffect(() => {
if (messagesContainerRef.current && isAtBottom) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [filteredMessages, isAtBottom]);
// Track scroll position
useEffect(() => {
const handleScroll = () => {
if (messagesContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
// Consider user at bottom if within 50px of bottom
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsAtBottom(atBottom);
// Reset new messages count when user scrolls back to bottom
if (atBottom) {
setNewMessagesCount(0);
setPreviousMessageCount(filteredMessages.length);
}
}
};
const container = messagesContainerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [filteredMessages.length]);
// Handle emote tooltip on hover
useEffect(() => {
const handleEmoteHover = (e: Event) => {
const target = e.target as HTMLImageElement;
if (!target.classList.contains('chat-emote')) return;
if (e.type === 'mouseenter') {
const tooltipUrl = target.getAttribute('data-tooltip-url');
const emoteName = target.getAttribute('data-emote-name') || 'Emote';
if (tooltipUrl) {
const tooltip = document.createElement('div');
tooltip.className = 'chat-emote-tooltip';
tooltip.id = 'emote-tooltip';
const img = document.createElement('img');
img.src = tooltipUrl;
img.alt = emoteName;
tooltip.appendChild(img);
const nameDiv = document.createElement('div');
nameDiv.className = 'chat-emote-tooltip-name';
nameDiv.textContent = emoteName;
tooltip.appendChild(nameDiv);
document.body.appendChild(tooltip);
// Position tooltip near the mouse
const rect = target.getBoundingClientRect();
tooltip.style.left = (rect.left + window.scrollX) + 'px';
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 10) + 'px';
}
} else if (e.type === 'mouseleave') {
const tooltip = document.getElementById('emote-tooltip');
if (tooltip) {
tooltip.remove();
}
}
};
const messagesContainer = messagesContainerRef.current;
if (messagesContainer) {
messagesContainer.addEventListener('mouseenter', handleEmoteHover, true);
messagesContainer.addEventListener('mouseleave', handleEmoteHover, true);
}
return () => {
if (messagesContainer) {
messagesContainer.removeEventListener('mouseenter', handleEmoteHover, true);
messagesContainer.removeEventListener('mouseleave', handleEmoteHover, true);
}
};
}, []);
// Helper to render message icon for special events
const renderMessageIcon = (msg: ChatMessage) => {
if (!msg.messageType) return null;
const iconConfig: Record<string, { title: string; color: string }> = {
cheer: { title: 'Cheer', color: '#FFB700' },
subscription: { title: 'Subscription', color: '#9147FF' },
resub: { title: 'Resub', color: '#9147FF' },
subgift: { title: 'Subgift', color: '#9147FF' },
redemption: { title: 'Channel Points Redemption', color: '#9147FF' },
};
const config = iconConfig[msg.messageType];
if (!config) return null;
let iconElement;
switch (msg.messageType) {
case 'cheer':
iconElement = <GiHeartBeats size={16} />;
break;
case 'subscription':
iconElement = <MdStars size={16} />;
break;
case 'resub':
iconElement = <MdAutorenew size={16} />;
break;
case 'subgift':
iconElement = <MdCardGiftcard size={16} />;
break;
case 'redemption':
iconElement = <MdRedeem size={16} />;
break;
default:
return null;
}
return (
<span
title={config.title}
style={{
marginRight: '6px',
display: 'inline-flex',
alignItems: 'center',
color: config.color,
filter: `drop-shadow(0 0 2px ${config.color})`,
}}
>
{iconElement}
</span>
);
};
// Helper to render badges
const renderBadges = (msg: ChatMessage) => {
// Prefer detailed badge info if available (contains titles/descriptions)
if (msg.badgeDetails && Object.keys(msg.badgeDetails).length > 0) {
return (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badgeDetails).map(([name, detail]) => (
<img
key={name}
src={detail.url}
alt={detail.title}
className="badge"
title={detail.title}
loading="eager"
style={{
width: '18px',
height: '18px',
verticalAlign: 'middle'
}}
/>
))}
</div>
);
}
// Fallback for platform-agnostic or historical badges
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
return (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badges).map(([name, badgeUrl]) => {
if (!badgeUrl) return null;
return (
<img
key={`${name}`}
src={badgeUrl}
alt={name}
className="badge"
title={name}
loading="eager"
style={{
width: '18px',
height: '18px',
verticalAlign: 'middle'
}}
/>
);
})}
</div>
);
};
return (
<div className="chat-display">
<div className="chat-header">
<div className="chat-header-content">
{onToggleSidebar && (
<button
onClick={onToggleSidebar}
className="sidebar-toggle-button"
title={sidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
>
{sidebarVisible ? '◀' : '▶'}
</button>
)}
{!showStreamEmbed && onToggleStreamEmbed && (
<button
onClick={onToggleStreamEmbed}
className="show-stream-button"
title="Show stream embed"
>
Show Stream
</button>
)}
<h2>
{isStreamLive ? (
<span className="status-badge-live">
<span className="status-dot pulse" />
LIVE
</span>
) : (
<span className="status-badge-offline">
<span className="status-dot" />
OFFLINE
</span>
)}
<a
href={`https://twitch.tv/${channelName}`}
target="_blank"
rel="noopener noreferrer"
className="channel-link"
>
{channelDisplayName || channelName || 'Chat'}
</a>
{viewerCount !== undefined && viewerCount !== null && (
<span className="viewer-count-display">
<FaUsers size={14} /> {viewerCount.toLocaleString()}
</span>
)}
{streamUptime && isStreamLive && (
<span className="uptime-display">
({streamUptime})
</span>
)}
</h2>
{streamTitle && <span className="stream-title">{streamTitle}</span>}
</div>
<div className="chat-header-actions">
{onRefreshEmotes && (
<button
onClick={onRefreshEmotes}
disabled={isRefreshingEmotes}
className="header-button"
title={isRefreshingEmotes ? 'Reloading emotes...' : 'Reload all emotes (clears cache)'}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<MdRefresh size={14} style={{ animation: isRefreshingEmotes ? 'spin 1s linear infinite' : 'none' }} />
{isRefreshingEmotes ? 'Reloading...' : 'Reload Emotes'}
</button>
)}
{isLoading && <span className="loading-indicator"></span>}
</div>
</div>
{unifiedChatSlot ? (
unifiedChatSlot
) : (
<div
className="messages-container"
ref={messagesContainerRef}
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
>
{filteredMessages.length === 0 ? (
<div className="no-messages">
<p>No messages yet. Start chatting!</p>
</div>
) : (
filteredMessages.map((msg) => {
const isMentioned = currentUserDisplayName && msg.content.includes(currentUserDisplayName);
const messageClasses = [
'message',
msg.messageType || 'chat',
msg.isPreloaded ? 'preloaded' : '',
msg.isBot ? 'bot' : '',
((msg.mentionsUser || isMentioned) && !msg.isBot) ? 'mention' : ''
].filter(Boolean).join(' ');
return (
<div key={msg.id} className={messageClasses}>
{msg.replyTo && (
<div className="reply-info">
<span>response to{' '}
<span
style={{
color: authorColorById.get(msg.replyTo.id) || '#94a3b8',
}}
>
@{msg.replyTo.author}
</span>
{msg.replyTo.content && (
<>
{': '}
<span
className="reply-target-content"
dangerouslySetInnerHTML={{
__html: parseMessageWithEmotes(msg.replyTo.content)
}}
/>
</>
)}
</span>
</div>
)}
<div className="message-single-line">
<span className="timestamp">
{msg.timestamp.toLocaleTimeString()}
</span>
{msg.authorAvatar && (
<img
src={msg.authorAvatar}
alt={msg.author}
className="avatar"
/>
)}
{renderMessageIcon(msg)}
{renderBadges(msg)}
<a
href={`https://twitch.tv/${msg.author}`}
target="_blank"
rel="noopener noreferrer"
className={`author author-link ${msg.isCurrentUser ? 'self' : ''}`}
style={{
color: msg.isCurrentUser ? '#ffffff' : (msg.authorColor || '#e2e8f0'),
}}
>
{msg.author}
</a>
<span className="message-separator">:</span>
<span
className="message-text"
dangerouslySetInnerHTML={{
__html: parseMessageWithEmotes(msg.content, msg.emotes),
}}
/>
<div className="message-actions">
{onReply && (
<button
onClick={() => onReply(msg)}
className="action-button"
title="Reply to this message"
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<MdReply size={12} />
</button>
)}
<button
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(msg.content).then(() => {
onCopyMessage?.();
}).catch((err) => {
console.error('Failed to copy:', err);
});
} else {
// Fallback for environments without clipboard API
const textArea = document.createElement('textarea');
textArea.value = msg.content;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
onCopyMessage?.();
} catch (err) {
console.error('Failed to copy:', err);
}
document.body.removeChild(textArea);
}
}}
className="action-button"
title="Copy message"
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<MdContentCopy size={12} />
</button>
</div>
</div>
</div>
);
})
)}
</div>
)}
{!isAtBottom && !unifiedChatSlot && (
<button
onClick={() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
setIsAtBottom(true);
setNewMessagesCount(0);
setPreviousMessageCount(filteredMessages.length);
}
}}
className="go-to-bottom-button"
title="Go to latest messages"
>
{newMessagesCount > 0 ? `New Messages (${newMessagesCount})` : 'New Messages'}
</button>
)}
</div>
);
}
export default memo(ChatDisplay);

View File

@@ -0,0 +1,271 @@
import React from 'react';
import { FaTimes } from 'react-icons/fa';
interface EmbedConfigModalProps {
embedDefaultOpen: boolean;
onEmbedDefaultOpenChange: (value: boolean) => void;
unifyChatEnabled: boolean;
onUnifyChatEnabledChange: (value: boolean) => void;
fontSize: number;
onFontSizeChange: (value: number) => void;
mentionSoundEnabled: boolean;
onMentionSoundEnabledChange: (value: boolean) => void;
onClose: () => void;
}
export default function EmbedConfigModal({
embedDefaultOpen,
onEmbedDefaultOpenChange,
unifyChatEnabled,
onUnifyChatEnabledChange,
fontSize,
onFontSizeChange,
mentionSoundEnabled,
onMentionSoundEnabledChange,
onClose,
}: EmbedConfigModalProps) {
return (
<div
style={{
position: 'fixed',
inset: '0',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: '1000',
}}
onClick={onClose}
>
<div
style={{
backgroundColor: '#1a1a1a',
borderRadius: '8px',
border: '1px solid #333333',
padding: '24px',
maxWidth: '400px',
width: '90%',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.8)',
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<h2
style={{
margin: 0,
color: '#e2e8f0',
fontSize: '18px',
fontWeight: '600',
}}
>
Global Settings
</h2>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: '#999',
cursor: 'pointer',
padding: '0',
display: 'flex',
alignItems: 'center',
lineHeight: '1',
}}
title="Close"
>
<FaTimes size={20} />
</button>
</div>
<div style={{ marginBottom: '24px' }}>
<label
style={{
display: 'block',
marginBottom: '12px',
color: '#e2e8f0',
fontSize: '14px',
fontWeight: '500',
}}
>
Chat Font Size: <span style={{ color: '#6366f1', marginLeft: '4px' }}>{fontSize}px</span>
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '12px', color: '#94a3b8' }}>12px</span>
<input
type="range"
min="12"
max="32"
step="1"
value={fontSize}
onChange={(e) => onFontSizeChange(parseInt(e.target.value))}
style={{
flex: 1,
cursor: 'pointer',
accentColor: '#6366f1',
height: '4px',
}}
/>
<span style={{ fontSize: '12px', color: '#94a3b8' }}>32px</span>
</div>
<p
style={{
marginTop: '8px',
color: '#94a3b8',
fontSize: '12px',
}}
>
Adjust the size of the text in the chat window.
</p>
</div>
<div style={{ marginBottom: '24px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
color: '#e2e8f0',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={embedDefaultOpen}
onChange={(e) => onEmbedDefaultOpenChange(e.target.checked)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer',
accentColor: '#6366f1',
}}
/>
<span style={{ fontSize: '14px' }}>
Open stream embed by default
</span>
</label>
<p
style={{
marginTop: '8px',
color: '#94a3b8',
fontSize: '12px',
margin: '8px 0 0 30px',
}}
>
If enabled, the stream embed will be visible when you open Mixchat. If disabled, the chat will take up the full width.
</p>
</div>
<div style={{ marginBottom: '24px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
color: '#e2e8f0',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={unifyChatEnabled}
onChange={(e) => onUnifyChatEnabledChange(e.target.checked)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer',
accentColor: '#6366f1',
}}
/>
<span style={{ fontSize: '14px' }}>
Unify chats
</span>
</label>
<p
style={{
marginTop: '8px',
color: '#94a3b8',
fontSize: '12px',
margin: '8px 0 0 30px',
}}
>
When enabled, Twitch and YouTube messages appear together in a single chronological feed. Each platform keeps its own styling and features.
</p>
</div>
<div style={{ marginBottom: '24px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
color: '#e2e8f0',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={mentionSoundEnabled}
onChange={(e) => onMentionSoundEnabledChange(e.target.checked)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer',
accentColor: '#6366f1',
}}
/>
<span style={{ fontSize: '14px' }}>
Play sound on mention
</span>
</label>
<p
style={{
marginTop: '8px',
color: '#94a3b8',
fontSize: '12px',
margin: '8px 0 0 30px',
}}
>
Plays a notification sound when someone mentions your name or replies to your message.
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={onClose}
style={{
padding: '8px 16px',
border: '1px solid #333333',
backgroundColor: '#0a0a0a',
color: '#e2e8f0',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor = '#1a1a1a';
(e.currentTarget as HTMLButtonElement).style.borderColor = '#555555';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor = '#0a0a0a';
(e.currentTarget as HTMLButtonElement).style.borderColor = '#333333';
}}
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import React, { useState, useRef, useEffect, memo } from 'react';
import { FaSearch } from 'react-icons/fa';
import type { EmotesBySource } from '../lib/emotes';
import { toggleDisableEmote, getDisabledEmotes } from '../lib/emotes';
import '../styles/EmoteMenu.css';
interface EmoteMenuProps {
emotes: EmotesBySource;
onSelectEmote: (emoteName: string) => void;
isOpen: boolean;
onClose: () => void;
}
function EmoteMenu({ emotes, onSelectEmote, isOpen, onClose }: EmoteMenuProps) {
const [searchQuery, setSearchQuery] = useState('');
const [filteredEmotes, setFilteredEmotes] = useState<EmotesBySource>(emotes);
const [activeSection, setActiveSection] = useState<'twitch' | '7tv' | 'bttv' | 'ffz'>('twitch');
const [disabledSet, setDisabledSet] = useState<Set<string>>(new Set());
const menuRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({
twitch: null,
'7tv': null,
bttv: null,
ffz: null,
});
const scrollToSection = (section: string) => {
setActiveSection(section as any);
const sectionElement = sectionRefs.current[section];
if (sectionElement && contentRef.current) {
const scrollTop = sectionElement.offsetTop - contentRef.current.offsetTop;
contentRef.current.scrollTo({ top: scrollTop, behavior: 'smooth' });
}
};
// Filter emotes based on search query
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredEmotes(emotes);
return;
}
const lowerQuery = searchQuery.toLowerCase();
setFilteredEmotes({
twitch: emotes.twitch.filter(e => e.name.toLowerCase().includes(lowerQuery)),
'7tv': emotes['7tv'].filter(e => e.name.toLowerCase().includes(lowerQuery)),
bttv: emotes.bttv.filter(e => e.name.toLowerCase().includes(lowerQuery)),
ffz: emotes.ffz.filter(e => e.name.toLowerCase().includes(lowerQuery)),
});
}, [searchQuery, emotes]);
// Reset search and display all emotes when menu opens
useEffect(() => {
if (isOpen) {
setSearchQuery('');
setActiveSection('twitch');
setFilteredEmotes(emotes);
setDisabledSet(getDisabledEmotes());
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}
}, [isOpen, emotes]);
// Update disabled emotes when global emote state changes
useEffect(() => {
const handler = () => setDisabledSet(getDisabledEmotes());
window.addEventListener('emotes-reloaded', handler);
return () => window.removeEventListener('emotes-reloaded', handler);
}, []);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, onClose]);
// Handle keyboard escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const sources = [
{ key: 'twitch', label: 'Twitch', color: '#9146ff' },
{ key: '7tv' as const, label: '7TV', color: '#00d4ff' },
{ key: 'bttv', label: 'BTTV', color: '#34ae7b' },
{ key: 'ffz', label: 'FFZ', color: '#faa61a' },
] as const;
return (
<div className="emote-menu-overlay">
<div ref={menuRef} className="emote-menu-modal">
<div className="emote-menu-header">
<h2>Emotes</h2>
<button className="emote-menu-close" onClick={onClose}>
</button>
</div>
<div className="emote-menu-search">
<FaSearch size={16} style={{ color: '#94a3b8' }} />
<input
ref={searchInputRef}
type="text"
placeholder="Search emotes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="emote-search-input"
/>
</div>
<div className="emote-menu-tabs">
{sources.map(({ key, label, color }) => (
<button
key={key}
className={`emote-tab ${activeSection === key ? 'active' : ''}`}
onClick={() => scrollToSection(key)}
style={{
color: activeSection === key ? color : '#cbd5e1',
borderBottomColor: activeSection === key ? color : 'transparent',
}}
>
{label}
</button>
))}
</div>
<div className="emote-menu-content" ref={contentRef}>
{sources.map(({ key, label, color }) => {
const sourceEmotes = filteredEmotes[key];
return (
<div
key={key}
className="emote-source-section"
ref={(el) => {
if (el) sectionRefs.current[key] = el;
}}
>
<div className="emote-source-header">
<div
className="emote-source-dot"
style={{ backgroundColor: color }}
/>
<h3 style={{ color }}>{label}</h3>
<span className="emote-source-count">({sourceEmotes.length})</span>
</div>
{sourceEmotes.length > 0 ? (
<div className="emote-grid">
{sourceEmotes.map((emote) => {
const isDisabled = disabledSet.has(emote.name);
return (
<div
key={emote.name}
className={`emote-item ${isDisabled ? 'emote-disabled' : ''}`}
onClick={() => {
if (!isDisabled) {
onSelectEmote(emote.name);
setSearchQuery('');
}
}}
title={emote.name}
>
<button
className="emote-disable-btn"
onClick={(e) => {
e.stopPropagation();
toggleDisableEmote(emote.name);
setDisabledSet(getDisabledEmotes());
}}
title={isDisabled ? 'Enable emote' : 'Disable emote'}
>
{isDisabled ? 'Enable' : 'Disable'}
</button>
<img
src={emote.url}
alt={emote.name}
className="emote-image"
/>
<span className="emote-name">{emote.name}</span>
</div>
);
})}
</div>
) : (
<div className="emote-no-results-section">
No {label} emotes available
</div>
)}
</div>
);
})}
{Object.values(filteredEmotes).every(arr => arr.length === 0) && (
<div className="emote-no-results">
No emotes found matching "{searchQuery}"
</div>
)}
</div>
</div>
</div>
);
}
export default memo(EmoteMenu);

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import type { LiveStreamer } from '../lib/streamMonitor';
import '../styles/LiveNotification.css';
interface LiveNotificationProps {
streamer: LiveStreamer;
onViewNow: () => void;
onClose: () => void;
}
export default function LiveNotification({
streamer,
onViewNow,
onClose,
}: LiveNotificationProps) {
return (
<div className="live-notification">
<div className="live-notification-header">
<div className="live-notification-title">
<span className="live-badge">LIVE</span>
<span className="streamer-name">{streamer.displayName}</span>
</div>
<button className="live-notification-close" onClick={onClose} aria-label="Close">
<FaTimes />
</button>
</div>
{streamer.profileImageUrl && (
<img
src={streamer.profileImageUrl}
alt={streamer.displayName}
className="streamer-avatar"
/>
)}
{streamer.title && <div className="stream-title">{streamer.title}</div>}
{streamer.viewerCount && (
<div className="viewer-count">👥 {streamer.viewerCount.toLocaleString()} watching</div>
)}
<button className="view-now-button" onClick={onViewNow}>
View Now
</button>
</div>
);
}

View File

@@ -0,0 +1,285 @@
import React, { memo } from 'react';
import { FaStar, FaRegStar, FaUsers, FaEye, FaYoutube, FaCog } from 'react-icons/fa';
import EmbedConfigModal from './EmbedConfigModal';
interface TwitchChannel {
id: string;
name: string;
displayName: string;
title: string;
profileImageUrl?: string;
viewerCount?: number;
}
interface LoginPanelProps {
twitchConnected: boolean;
twitchUser?: string;
twitchProfileImage?: string;
twitchUserName?: string;
twitchChannels?: TwitchChannel[];
selectedTwitchChannel?: string;
favoriteChannels?: string[];
onChannelChange?: (channelId: string) => void;
onToggleFavorite?: (channelId: string) => void;
onTwitchLogin: () => void;
onTwitchLogout: () => void;
youtubeConnected?: boolean;
youtubeUser?: string;
onYoutubeLogin?: () => void;
onYoutubeLogout?: () => void;
onManageWatchlist?: () => void;
onManageYoutubeLinks?: () => void;
embedDefaultOpen?: boolean;
onEmbedDefaultOpenChange?: (value: boolean) => void;
unifyChatEnabled?: boolean;
onUnifyChatEnabledChange?: (value: boolean) => void;
fontSize?: number;
onFontSizeChange?: (value: number) => void;
mentionSoundEnabled?: boolean;
onMentionSoundEnabledChange?: (value: boolean) => void;
}
function LoginPanel({
twitchConnected,
twitchUser,
twitchProfileImage,
twitchUserName,
twitchChannels = [],
selectedTwitchChannel = '',
favoriteChannels = [],
onChannelChange,
onToggleFavorite,
onTwitchLogin,
onTwitchLogout,
youtubeConnected,
youtubeUser,
onYoutubeLogin,
onYoutubeLogout,
onManageWatchlist,
onManageYoutubeLinks,
embedDefaultOpen = true,
onEmbedDefaultOpenChange,
unifyChatEnabled = false,
onUnifyChatEnabledChange,
fontSize = 14,
onFontSizeChange,
mentionSoundEnabled = true,
onMentionSoundEnabledChange,
}: LoginPanelProps) {
const [manualChannel, setManualChannel] = React.useState('');
const [searchQuery, setSearchQuery] = React.useState('');
const [showEmbedConfig, setShowEmbedConfig] = React.useState(false);
const filteredChannels = React.useMemo(() => {
if (!searchQuery.trim()) return twitchChannels;
const query = searchQuery.toLowerCase();
return twitchChannels.filter(
(channel) =>
channel.displayName.toLowerCase().includes(query) ||
channel.name.toLowerCase().includes(query)
);
}, [searchQuery, twitchChannels]);
const handleManualJoin = (e: React.FormEvent) => {
e.preventDefault();
if (manualChannel.trim() && onChannelChange) {
onChannelChange(manualChannel.trim().toLowerCase());
setManualChannel('');
}
};
return (
<div className="login-panel">
<div className="login-panel-header">
<h3>Connected Accounts</h3>
<button
onClick={() => setShowEmbedConfig(true)}
className="icon-button small"
title="Configure global settings"
>
<FaCog />
</button>
</div>
<div className="platform-login twitch">
<div className="platform-header">
<span className="platform-name">Twitch</span>
</div>
{twitchConnected && twitchUser ? (
<div className="connected-info">
<div className="user-profile-container">
<div
className="user-profile-link"
onClick={() => twitchUserName && onChannelChange?.(twitchUserName)}
title={`Switch to ${twitchUser}'s channel`}
>
{twitchProfileImage && (
<img src={twitchProfileImage} alt={twitchUser} className="profile-image" loading="eager" />
)}
<div className="user-details">
<p className="user-name">{twitchUser}</p>
</div>
</div>
<div className="user-actions">
{onManageWatchlist && (
<button
onClick={onManageWatchlist}
className="icon-button"
title="Manage Notification Streams"
>
<FaEye />
</button>
)}
{onManageYoutubeLinks && (
<button
onClick={onManageYoutubeLinks}
className="icon-button youtube-icon-btn"
title="Link YouTube channels"
>
<FaYoutube />
</button>
)}
</div>
</div>
<button onClick={onTwitchLogout} className="logout-btn">
Logout
</button>
{twitchChannels && twitchChannels.length > 0 && (
<div className="channels-section">
<label className="section-label">
Channels ({filteredChannels.length})
</label>
<input
type="text"
placeholder="Search channels..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="channel-search-input"
/>
<div className="channel-list">
{filteredChannels.map((channel) => (
<div
key={channel.id}
className={`channel-item ${selectedTwitchChannel === channel.id ? 'selected' : ''}`}
onClick={() => onChannelChange?.(channel.id)}
title={channel.displayName}
>
{channel.profileImageUrl && (
<img
src={channel.profileImageUrl}
alt={channel.displayName}
className="channel-item-image"
/>
)}
<span className="channel-item-name">
{channel.displayName}
</span>
{channel.viewerCount !== undefined && channel.viewerCount !== null && (
<span className="viewer-count">
<FaUsers size={12} /> {channel.viewerCount.toLocaleString()}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation(); // Don't also select the channel when toggling a favorite
onToggleFavorite?.(channel.id);
}}
className={`favorite-button ${favoriteChannels.includes(channel.id) ? 'active' : ''}`}
title={favoriteChannels.includes(channel.id) ? 'Remove from favorites' : 'Add to favorites'}
>
{favoriteChannels.includes(channel.id) ? <FaStar size={16} /> : <FaRegStar size={16} />}
</button>
</div>
))}
</div>
<div className="manual-join-section">
<label className="section-label">
Or join manually
</label>
<form onSubmit={handleManualJoin} className="join-form">
<input
type="text"
value={manualChannel}
onChange={(e) => setManualChannel(e.target.value)}
placeholder="Enter channel name"
className="join-input"
/>
<button
type="submit"
disabled={!manualChannel.trim()}
className="logout-btn"
style={{
backgroundColor: manualChannel.trim() ? '#555555' : '#333333',
cursor: manualChannel.trim() ? 'pointer' : 'not-allowed',
}}
>
Join
</button>
</form>
</div>
</div>
)}
</div>
) : (
<button onClick={onTwitchLogin} className="login-btn twitch-btn">
Login with Twitch
</button>
)}
{/* YouTube login/logout */}
<div className="youtube-section">
{youtubeConnected ? (
<div className="youtube-info-row">
<div className="youtube-user-info">
<FaYoutube style={{ color: '#FF0000', fontSize: '18px', flexShrink: 0 }} />
<span className="youtube-user-name">{youtubeUser || 'YouTube'}</span>
</div>
<button onClick={onYoutubeLogout} className="logout-btn small">
Logout
</button>
</div>
) : onYoutubeLogin ? (
<button onClick={onYoutubeLogin} className="login-btn youtube-btn" style={{ width: '100%' }}>
Login with YouTube
</button>
) : null}
</div>
</div>
{showEmbedConfig && (
<EmbedConfigModal
embedDefaultOpen={embedDefaultOpen}
onEmbedDefaultOpenChange={(value) => {
if (onEmbedDefaultOpenChange) {
onEmbedDefaultOpenChange(value);
}
}}
unifyChatEnabled={unifyChatEnabled}
onUnifyChatEnabledChange={(value) => {
if (onUnifyChatEnabledChange) {
onUnifyChatEnabledChange(value);
}
}}
fontSize={fontSize}
onFontSizeChange={(value) => {
if (onFontSizeChange) {
onFontSizeChange(value);
}
}}
mentionSoundEnabled={mentionSoundEnabled}
onMentionSoundEnabledChange={(value) => {
if (onMentionSoundEnabledChange) {
onMentionSoundEnabledChange(value);
}
}}
onClose={() => setShowEmbedConfig(false)}
/>
)}
</div>
);
}
export default memo(LoginPanel);

View File

@@ -0,0 +1,518 @@
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
import { FaTimes, FaSmile, FaTwitch } from 'react-icons/fa';
import type { Platform, ChatMessage } from '../lib/types';
import { searchEmotes, getAllEmotesOrganized, type EmotesBySource } from '../lib/emotes';
import EmoteMenu from './EmoteMenu';
interface MessageInputProps {
onSendMessage: (message: string, platforms: Platform[], replyToId?: string) => Promise<void>;
isLoading: boolean;
isConnected: { twitch: boolean };
replyingTo?: ChatMessage;
onCancelReply?: () => void;
chatMessages?: ChatMessage[];
currentUsername?: string;
}
function MessageInput({
onSendMessage,
isLoading,
isConnected,
replyingTo,
onCancelReply,
chatMessages = [],
currentUsername = '',
}: MessageInputProps) {
const [message, setMessage] = useState('');
const [placeholder, setPlaceholder] = useState('Type your message... (Shift+Enter for new line)');
const [selectedPlatforms, setSelectedPlatforms] = useState<Platform[]>([
'twitch',
]);
const [emoteQuery, setEmoteQuery] = useState('');
const [emoteResults, setEmoteResults] = useState<Array<{ name: string; url: string; source: string }>>([]);
const [selectedEmoteIndex, setSelectedEmoteIndex] = useState(-1);
const [hasEmoteSelection, setHasEmoteSelection] = useState(false);
const [showEmoteMenu, setShowEmoteMenu] = useState(false);
const [showEmotePickerMenu, setShowEmotePickerMenu] = useState(false);
const [emotesOrganized, setEmotesOrganized] = useState<EmotesBySource>({
twitch: [],
'7tv': [],
bttv: [],
ffz: [],
});
const [usernameQuery, setUsernameQuery] = useState('');
const [usernameResults, setUsernameResults] = useState<string[]>([]);
const [selectedUsernameIndex, setSelectedUsernameIndex] = useState(-1);
const [showUsernameMenu, setShowUsernameMenu] = useState(false);
const [hasUsernameSelection, setHasUsernameSelection] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const emoteMenuRef = useRef<HTMLDivElement>(null);
const shouldRefocusRef = useRef(false);
// Load organized emotes on component mount
useEffect(() => {
const organized = getAllEmotesOrganized();
setEmotesOrganized(organized);
// Listen for emotes-reloaded event
function handleEmotesReloaded() {
const updated = getAllEmotesOrganized();
setEmotesOrganized(updated);
}
window.addEventListener('emotes-reloaded', handleEmotesReloaded);
return () => {
window.removeEventListener('emotes-reloaded', handleEmotesReloaded);
};
}, []);
// Refresh emotes whenever the emote picker menu opens
useEffect(() => {
if (showEmotePickerMenu) {
const organized = getAllEmotesOrganized();
setEmotesOrganized(organized);
}
}, [showEmotePickerMenu]);
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newMessage = e.target.value;
setMessage(newMessage);
// Get cursor position
const cursorPos = e.target.selectionStart;
const textBeforeCursor = newMessage.substring(0, cursorPos);
// Find the last word being typed (after space or at start)
const lastSpaceIndex = textBeforeCursor.lastIndexOf(' ');
const currentWord = textBeforeCursor.substring(lastSpaceIndex + 1);
// Check for @ mentions
if (currentWord.startsWith('@') && currentWord.length > 1) {
const query = currentWord.substring(1).toLowerCase();
const uniqueUsernames = new Set(chatMessages.map((msg) => msg.author));
const matching = Array.from(uniqueUsernames)
.filter((name) => name.toLowerCase().includes(query) && name.toLowerCase() !== currentUsername.toLowerCase())
.sort();
if (matching.length > 0) {
setUsernameQuery(query);
setUsernameResults(matching);
setSelectedUsernameIndex(-1);
setHasUsernameSelection(false);
setShowUsernameMenu(true);
setShowEmoteMenu(false);
} else {
setShowUsernameMenu(false);
}
}
// Show emote autocomplete if typing a word
else if (currentWord.length > 0 && !currentWord.startsWith('@')) {
const results = searchEmotes(currentWord);
if (results.length > 0) {
setEmoteQuery(currentWord);
setEmoteResults(results);
setSelectedEmoteIndex(-1);
setHasEmoteSelection(false);
setShowEmoteMenu(true);
setShowUsernameMenu(false);
} else {
setShowEmoteMenu(false);
setShowUsernameMenu(false);
}
} else {
setShowEmoteMenu(false);
setShowUsernameMenu(false);
}
};
const insertEmote = (emoteName: string) => {
if (!textareaRef.current) return;
const cursorPos = textareaRef.current.selectionStart;
const textBeforeCursor = message.substring(0, cursorPos);
const textAfterCursor = message.substring(cursorPos);
// If message is empty, just insert the emote with a space after
if (!textBeforeCursor.trim()) {
const newMessage = emoteName + ' ' + textAfterCursor;
setMessage(newMessage);
setShowEmoteMenu(false);
setShowEmotePickerMenu(false);
setHasEmoteSelection(false);
// Set cursor after the inserted emote
setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = (emoteName + ' ').length;
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
return;
}
// Find the start of the current word
const lastSpaceIndex = textBeforeCursor.lastIndexOf(' ');
const beforeWord = textBeforeCursor.substring(0, lastSpaceIndex + 1);
// Insert emote
const newMessage = beforeWord + emoteName + ' ' + textAfterCursor;
setMessage(newMessage);
setShowEmoteMenu(false);
setShowEmotePickerMenu(false);
setHasEmoteSelection(false);
// Set cursor after the inserted emote
setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = (beforeWord + emoteName + ' ').length;
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
};
const insertUsername = (username: string) => {
if (!textareaRef.current) return;
const cursorPos = textareaRef.current.selectionStart;
const textBeforeCursor = message.substring(0, cursorPos);
const textAfterCursor = message.substring(cursorPos);
// Find the start of the @ mention
const lastSpaceIndex = textBeforeCursor.lastIndexOf(' ');
const beforeMention = textBeforeCursor.substring(0, lastSpaceIndex + 1);
// Insert username with @
const newMessage = beforeMention + '@' + username + ' ' + textAfterCursor;
setMessage(newMessage);
setShowUsernameMenu(false);
setHasUsernameSelection(false);
setSelectedUsernameIndex(-1);
// Set cursor after the inserted username
setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = (beforeMention + '@' + username + ' ').length;
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
};
const handleSend = async () => {
if (!message.trim() || selectedPlatforms.length === 0) return;
try {
shouldRefocusRef.current = true;
await onSendMessage(message, selectedPlatforms, replyingTo?.id);
setMessage('');
setShowEmoteMenu(false);
setShowEmotePickerMenu(false);
if (onCancelReply) onCancelReply();
} catch (error) {
console.error('Failed to send message:', error);
}
};
const handleToggleEmotePicker = () => {
setShowEmotePickerMenu(!showEmotePickerMenu);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
// If username menu is open, handle username navigation
if (showUsernameMenu) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedUsernameIndex((prev) =>
prev < usernameResults.length - 1 ? prev + 1 : prev
);
setHasUsernameSelection(true);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedUsernameIndex((prev) => (prev > 0 ? prev - 1 : 0));
setHasUsernameSelection(true);
} else if (e.key === 'Enter') {
if (hasUsernameSelection && selectedUsernameIndex >= 0 && usernameResults[selectedUsernameIndex]) {
e.preventDefault();
insertUsername(usernameResults[selectedUsernameIndex]);
} else {
e.preventDefault();
setShowUsernameMenu(false);
handleSend();
}
} else if (e.key === 'Escape') {
e.preventDefault();
setShowUsernameMenu(false);
setSelectedUsernameIndex(-1);
setHasUsernameSelection(false);
} else if (e.key === 'Tab') {
if (hasUsernameSelection && selectedUsernameIndex >= 0 && usernameResults[selectedUsernameIndex]) {
e.preventDefault();
insertUsername(usernameResults[selectedUsernameIndex]);
}
}
return;
}
// If emote menu is open, handle emote navigation
if (showEmoteMenu) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedEmoteIndex((prev) =>
prev < emoteResults.length - 1 ? prev + 1 : prev
);
setHasEmoteSelection(true);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedEmoteIndex((prev) => (prev > 0 ? prev - 1 : 0));
setHasEmoteSelection(true);
} else if (e.key === 'Enter') {
if (!e.shiftKey && hasEmoteSelection && emoteResults[selectedEmoteIndex]) {
e.preventDefault();
insertEmote(emoteResults[selectedEmoteIndex].name);
} else if (!e.shiftKey) {
e.preventDefault();
setShowEmoteMenu(false);
handleSend();
}
} else if (e.key === 'Escape') {
e.preventDefault();
setShowEmoteMenu(false);
setHasEmoteSelection(false);
} else if (e.key === 'Tab') {
if (hasEmoteSelection && emoteResults[selectedEmoteIndex]) {
e.preventDefault();
insertEmote(emoteResults[selectedEmoteIndex].name);
}
}
return;
}
// No menus open, just handle regular input
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
emoteMenuRef.current &&
!emoteMenuRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setShowEmoteMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Refocus input after send completes
useEffect(() => {
if (!isLoading && shouldRefocusRef.current) {
shouldRefocusRef.current = false;
setTimeout(() => textareaRef.current?.focus(), 0);
}
}, [isLoading]);
// Scroll selected emote into view
useEffect(() => {
if (showEmoteMenu && emoteMenuRef.current) {
const selectedElement = emoteMenuRef.current.children[selectedEmoteIndex] as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' });
}
}
}, [selectedEmoteIndex, showEmoteMenu]);
// Focus textarea when replying to a message
useEffect(() => {
if (replyingTo && textareaRef.current) {
textareaRef.current.focus();
}
}, [replyingTo]);
return (
<div className="message-input">
{replyingTo && (
<div className="reply-indicator" style={{
padding: '8px 12px',
background: '#0f172a',
borderRadius: '4px',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
gap: '12px',
}}>
<div style={{ fontSize: '0.9rem', color: '#94a3b8' }}>
<span style={{ fontWeight: 'bold' }}>Replying to @{replyingTo.author}</span>
<span style={{ marginLeft: '8px', fontStyle: 'italic' }}>
{replyingTo.content.substring(0, 50)}{replyingTo.content.length > 50 ? '...' : ''}
</span>
</div>
{onCancelReply && (
<button
onClick={onCancelReply}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: '0 8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Cancel reply"
>
<FaTimes size={16} />
</button>
)}
</div>
)}
<div className="input-group" style={{ position: 'relative' }}>
<button
onClick={handleToggleEmotePicker}
disabled={selectedPlatforms.length === 0 || isLoading}
className="emote-picker-button"
title="Browse emotes"
>
<FaSmile size={18} />
</button>
<textarea
ref={textareaRef}
value={message}
onChange={handleMessageChange}
onKeyDown={handleKeyPress}
placeholder={placeholder}
disabled={selectedPlatforms.length === 0 || isLoading}
className="message-textarea twitch"
autoComplete="off"
/>
<button
onClick={handleSend}
disabled={
!message.trim() ||
selectedPlatforms.length === 0 ||
isLoading
}
className="send-button"
>
{isLoading ? '...' : <FaTwitch size={16} />}
</button>
{showEmoteMenu && emoteResults.length > 0 && (
<div
ref={emoteMenuRef}
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
maxHeight: '300px',
overflowY: 'auto',
background: '#1e293b',
border: '1px solid #475569',
borderRadius: '8px',
marginBottom: '8px',
zIndex: 1000,
}}
>
{emoteResults.map((emote, index) => (
<div
key={`${emote.source}-${emote.name}`}
onClick={() => insertEmote(emote.name)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 12px',
cursor: 'pointer',
background: index === selectedEmoteIndex ? '#334155' : 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={() => setSelectedEmoteIndex(index)}
>
<img
src={emote.url}
alt={emote.name}
style={{
width: '32px',
height: '32px',
objectFit: 'contain',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span style={{ color: '#e2e8f0', fontWeight: '500' }}>{emote.name}</span>
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>{emote.source}</span>
</div>
</div>
))}
</div>
)}
{showUsernameMenu && usernameResults.length > 0 && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
maxHeight: '250px',
overflowY: 'auto',
background: '#1e293b',
border: '1px solid #475569',
borderRadius: '8px',
marginBottom: '8px',
zIndex: 1000,
}}
>
{usernameResults.map((username, index) => (
<div
key={username}
onClick={() => insertUsername(username)}
style={{
padding: '8px 12px',
cursor: 'pointer',
background: hasUsernameSelection && index === selectedUsernameIndex ? '#334155' : 'transparent',
transition: 'background 0.15s',
color: '#e2e8f0',
fontSize: '0.95rem',
}}
onMouseEnter={() => {
setSelectedUsernameIndex(index);
setHasUsernameSelection(true);
}}
>
@{username}
</div>
))}
</div>
)}
</div>
<EmoteMenu
emotes={emotesOrganized}
onSelectEmote={insertEmote}
isOpen={showEmotePickerMenu}
onClose={() => setShowEmotePickerMenu(false)}
/>
{selectedPlatforms.length === 0 && (
<div className="warning">
Select at least one platform to send messages
</div>
)}
</div>
);
}
export default memo(MessageInput);

View File

@@ -0,0 +1,34 @@
import React, { useState, useEffect } from 'react';
interface NotificationProps {
message: string;
type?: 'success' | 'error' | 'info';
duration?: number;
onClose?: () => void;
}
export default function Notification({
message,
type = 'info',
duration = 3000,
onClose,
}: NotificationProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
onClose?.();
}, duration);
return () => clearTimeout(timer);
}, [duration]);
if (!isVisible) return null;
return (
<div className={`notification notification-${type}`}>
<span>{message}</span>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect, useRef } from 'react';
import HLS from 'hls.js';
interface StreamEmbedProps {
channelName: string;
playerType?: 'embed' | 'm3u8';
twitchConnected?: boolean;
onTwitchLogin?: () => void;
}
export default function StreamEmbed({ channelName, playerType = 'embed', twitchConnected, onTwitchLogin }: StreamEmbedProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<HLS | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [parentDomain, setParentDomain] = useState<string>('localhost');
useEffect(() => {
fetch('/api/parent-domain')
.then(res => res.json())
.then(data => setParentDomain(data.parentDomain))
.catch(() => setParentDomain('localhost'));
}, []);
// No longer forcing iframe remount on channel switch to help preserve session state.
// The browser will handle navigating the existing iframe to the new URL.
useEffect(() => {
if (playerType === 'embed' || !channelName) {
setLoading(false);
return;
}
const loadStream = async () => {
try {
setLoading(true);
setError(null);
if (!videoRef.current) return;
// Clean up previous HLS instance
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
// /api/stream-playlist handles the full server-side proxy chain:
// Twitch GQL (token) → usher.ttvnw.net (master) → best variant →
// rewrite segment URLs to /api/stream-segment
// This avoids all browser CORS issues and IP-mismatch 403s.
const playlistUrl = `/api/stream-playlist?channel=${encodeURIComponent(channelName)}`;
if (HLS.isSupported()) {
const hls = new HLS({
debug: false,
enableWorker: true,
lowLatencyMode: true,
// Re-fetch the live playlist frequently
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
});
hls.on(HLS.Events.MANIFEST_PARSED, () => {
setLoading(false);
videoRef.current?.play().catch(() => {
// Auto-play failed — user will need to click play
});
});
hls.on(HLS.Events.ERROR, (event, data) => {
if (data.fatal) {
setError(`Stream error: ${data.type}`);
setLoading(false);
}
});
hls.loadSource(playlistUrl);
hls.attachMedia(videoRef.current);
hlsRef.current = hls;
} else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
// Safari supports HLS natively
videoRef.current.src = playlistUrl;
setLoading(false);
} else {
setError('HLS not supported in this browser');
setLoading(false);
}
} catch (err) {
console.error('Failed to load stream:', err);
setError(err instanceof Error ? err.message : 'Failed to load stream');
setLoading(false);
}
};
loadStream();
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
}, [channelName, playerType]);
if (!channelName) {
// If user is not logged in, show logo + Login with Twitch button
if (!twitchConnected) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: '#0a0a0a',
color: '#999',
fontSize: '14px',
flexDirection: 'column',
gap: '12px',
}}
>
<img src="/logo.png" alt="Mixchat" style={{ height: 115 }} />
<button
onClick={() => onTwitchLogin && onTwitchLogin()}
style={{
backgroundColor: '#6441a5',
color: '#fff',
border: 'none',
padding: '8px 12px',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Login with Twitch
</button>
</div>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: '#0a0a0a',
color: '#999',
fontSize: '14px',
}}
>
Select a channel to view stream
</div>
);
}
// Embed mode
if (playerType === 'embed') {
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<iframe
src={`https://embed.twitch.tv/?channel=${channelName}&parent=${parentDomain}&layout=video&autoplay=true`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
border: 'none',
display: 'block',
visibility: 'visible',
}}
frameBorder="0"
scrolling="no"
allowFullScreen={true}
title={`${channelName} Twitch Stream`}
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
/>
</div>
);
}
// M3U8 mode — error state
if (error) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
backgroundColor: '#0a0a0a',
color: '#ff4444',
fontSize: '14px',
gap: '8px',
padding: '16px',
textAlign: 'center',
}}
>
<div> {error}</div>
<div style={{ fontSize: '12px', color: '#666' }}>The stream may be offline or unavailable</div>
</div>
);
}
// M3U8 mode — video player
return (
<div style={{ height: '100%', width: '100%', backgroundColor: '#0a0a0a', position: 'relative' }}>
<video
ref={videoRef}
controls
style={{
width: '100%',
height: '100%',
display: 'block',
backgroundColor: '#0a0a0a',
}}
title={`${channelName} Twitch Stream`}
/>
{loading && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: '#999',
fontSize: '14px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{
width: '30px',
height: '30px',
border: '3px solid #333',
borderTop: '3px solid #6366f1',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
<div>Loading stream...</div>
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,506 @@
import React, { useEffect, useRef, useState, useCallback, memo } from 'react';
import { MdStars, MdAutorenew, MdCardGiftcard, MdReply, MdContentCopy } from 'react-icons/md';
import { GiHeartBeats } from 'react-icons/gi';
import { FaTwitch, FaYoutube } from 'react-icons/fa';
import type { ChatMessage } from '../lib/types';
import { parseMessageWithEmotes } from '../lib/emotes';
import '../styles/UnifiedChat.css';
// YouTube message shape — mirrors what YouTubeChat.tsx uses internally
interface MessagePart {
type: 'text' | 'emoji';
text?: string;
url?: string;
alt?: string;
emojiText?: string;
isCustomEmoji?: boolean;
}
export interface YTMessage {
id: string;
author: string;
authorAvatar?: string;
badges?: Record<string, string>;
parts: MessagePart[];
timestamp: Date;
authorChannelId?: string;
superchat?: {
amount: string;
color: string;
sticker?: {
url: string;
alt: string;
};
};
}
// A single entry in the merged list, tagged with its platform source
type UnifiedEntry =
| { source: 'twitch'; msg: ChatMessage }
| { source: 'youtube'; msg: YTMessage };
function getKey(e: UnifiedEntry) {
return e.source + ':' + e.msg.id;
}
function getTime(e: UnifiedEntry) {
return e.msg.timestamp instanceof Date
? e.msg.timestamp.getTime()
: new Date(e.msg.timestamp).getTime();
}
// Generates a consistent color from a username, same algorithm used in YouTubeChat
function getColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash) + name.charCodeAt(i);
hash = hash & hash;
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 55%)`;
}
interface UnifiedChatProps {
twitchMessages: ChatMessage[];
ytMessages: YTMessage[];
onReply?: (message: ChatMessage) => void;
onCopyMessage?: () => void;
currentUserDisplayName?: string;
fontSize?: number;
}
const MAX_MESSAGES = 300;
function UnifiedChat({
twitchMessages,
ytMessages,
onReply,
onCopyMessage,
currentUserDisplayName,
fontSize = 14,
}: UnifiedChatProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [newMessagesCount, setNewMessagesCount] = useState(0);
const prevCountRef = useRef(0);
// Merge Twitch and YouTube messages into one list sorted by time
const entries: UnifiedEntry[] = React.useMemo(() => {
const tw: UnifiedEntry[] = twitchMessages.map((m) => ({ source: 'twitch' as const, msg: m }));
const yt: UnifiedEntry[] = ytMessages.map((m) => ({ source: 'youtube' as const, msg: m }));
const merged = [...tw, ...yt].sort((a, b) => getTime(a) - getTime(b));
return merged.length > MAX_MESSAGES ? merged.slice(-MAX_MESSAGES) : merged;
}, [twitchMessages, ytMessages]);
// Scroll to the bottom whenever new messages arrive (only if already at bottom)
useEffect(() => {
if (containerRef.current && isAtBottom) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [entries, isAtBottom]);
// Track whether the user has scrolled away from the bottom
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleScroll = () => {
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setIsAtBottom(atBottom);
if (atBottom) {
setNewMessagesCount(0);
prevCountRef.current = entries.length;
}
};
el.addEventListener('scroll', handleScroll);
return () => el.removeEventListener('scroll', handleScroll);
}, [entries.length]);
useEffect(() => {
if (!isAtBottom) {
const diff = entries.length - prevCountRef.current;
if (diff > 0) setNewMessagesCount((p) => p + diff);
} else {
prevCountRef.current = entries.length;
}
}, [entries.length, isAtBottom]);
useEffect(() => {
if (!isAtBottom) {
const diff = entries.length - prevCountRef.current;
if (diff > 0) setNewMessagesCount((p) => p + diff);
} else {
prevCountRef.current = entries.length;
}
}, [entries.length, isAtBottom]);
// Handle emote tooltip on hover
useEffect(() => {
const handleEmoteHover = (e: Event) => {
const target = e.target as HTMLImageElement;
if (!target.classList.contains('chat-emote')) return;
// console.log('Emote hovered:', target.getAttribute('data-emote-name'));
if (e.type === 'mouseenter') {
const tooltipUrl = target.getAttribute('data-tooltip-url');
const emoteName = target.getAttribute('data-emote-name') || 'Emote';
if (tooltipUrl) {
const tooltip = document.createElement('div');
tooltip.className = 'chat-emote-tooltip';
tooltip.id = 'emote-tooltip';
const img = document.createElement('img');
img.src = tooltipUrl;
img.alt = emoteName;
tooltip.appendChild(img);
const nameDiv = document.createElement('div');
nameDiv.className = 'chat-emote-tooltip-name';
nameDiv.textContent = emoteName;
tooltip.appendChild(nameDiv);
document.body.appendChild(tooltip);
// Position tooltip near the mouse
const rect = target.getBoundingClientRect();
tooltip.style.left = (rect.left + window.scrollX) + 'px';
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 10) + 'px';
}
} else if (e.type === 'mouseleave') {
const tooltip = document.getElementById('emote-tooltip');
if (tooltip) {
tooltip.remove();
}
}
};
const messagesContainer = containerRef.current;
if (messagesContainer) {
messagesContainer.addEventListener('mouseenter', handleEmoteHover, true);
messagesContainer.addEventListener('mouseleave', handleEmoteHover, true);
}
return () => {
if (messagesContainer) {
messagesContainer.removeEventListener('mouseenter', handleEmoteHover, true);
messagesContainer.removeEventListener('mouseleave', handleEmoteHover, true);
}
// Cleanup tooltip if it exists when component unmounts
const tooltip = document.getElementById('emote-tooltip');
if (tooltip) tooltip.remove();
};
}, []);
// Small platform icon rendered before the username
const PlatformTag = ({ source }: { source: 'twitch' | 'youtube' }) => (
<span
title={source === 'twitch' ? "Twitch" : "YouTube"}
className={`platform-tag ${source}`}
>
{source === 'twitch' ? <FaTwitch size={12} /> : <FaYoutube size={12} />}
</span>
);
// Helpers for Twitch-specific UI elements (event icons, badges)
const renderTwitchIcon = (msg: ChatMessage) => {
if (!msg.messageType) return null;
const iconConfig: Record<string, { title: string; color: string }> = {
cheer: { title: 'Cheer', color: '#FFB700' },
subscription: { title: 'Subscription', color: '#9147FF' },
resub: { title: 'Resub', color: '#9147FF' },
subgift: { title: 'Subgift', color: '#9147FF' },
};
const cfg = iconConfig[msg.messageType];
if (!cfg) return null;
let icon;
switch (msg.messageType) {
case 'cheer': icon = <GiHeartBeats size={14} />; break;
case 'subscription': icon = <MdStars size={14} />; break;
case 'resub': icon = <MdAutorenew size={14} />; break;
case 'subgift': icon = <MdCardGiftcard size={14} />; break;
default: return null;
}
return (
<span
title={cfg.title}
className="twitch-event-icon"
style={{ color: cfg.color, filter: `drop-shadow(0 0 2px ${cfg.color})` }}
>
{icon}
</span>
);
};
const renderTwitchBadges = (msg: ChatMessage) => {
// Prefer detailed badge info if available (contains titles/descriptions)
if (msg.badgeDetails && Object.keys(msg.badgeDetails).length > 0) {
return (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badgeDetails).map(([name, detail]) => (
<img
key={name}
src={detail.url}
alt={detail.title}
className="badge"
title={detail.title}
loading="eager"
style={{
width: '18px',
height: '18px',
verticalAlign: 'middle'
}}
/>
))}
</div>
);
}
// Fallback for platform-agnostic or historical badges
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
return (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badges).map(([name, url]) => {
if (!url) return null;
return (
<img key={name} src={url} alt={name} className="badge" title={name} loading="eager"
style={{ width: '18px', height: '18px', verticalAlign: 'middle' }} />
);
})}
</div>
);
};
const renderYTBadges = (msg: YTMessage) => {
if (!msg.badges || Object.keys(msg.badges).length === 0) return null;
return (
<div className="message-badges" style={{ display: 'flex', alignItems: 'center', marginRight: '4px', gap: '2px' }}>
{Object.entries(msg.badges).map(([name, url]) => (
<img key={name} src={url} alt={name} className="badge" title={name}
style={{ width: '18px', height: '18px', verticalAlign: 'middle' }} />
))}
</div>
);
};
// Render the scrollable message list with an optional "new messages" button
return (
<div className="unified-chat-wrapper">
<div
ref={containerRef}
className="messages-container unified-messages-container"
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
>
{entries.length === 0 && (
<div className="no-messages">
<p>No messages yet. Start chatting!</p>
</div>
)}
{entries.map((entry) => {
if (entry.source === 'twitch') {
const msg = entry.msg as ChatMessage;
const isMentioned = currentUserDisplayName && msg.content.includes(currentUserDisplayName);
const messageClasses = [
'message',
msg.messageType,
msg.isPreloaded ? 'preloaded' : '',
msg.isBot ? 'bot' : '',
((msg.mentionsUser || isMentioned) && !msg.isBot) ? 'mention' : ''
].filter(Boolean).join(' ');
return (
<div key={getKey(entry)} className={messageClasses}>
{msg.replyTo && (
<div className="reply-info">
<span>
response to{' '}
<span style={{ color: '#94a3b8' }}>@{msg.replyTo.author}</span>
{msg.replyTo.content && (
<>
{': '}
<span
className="reply-target-content"
dangerouslySetInnerHTML={{
__html: parseMessageWithEmotes(msg.replyTo.content)
}}
/>
</>
)}
</span>
</div>
)}
<div className="message-single-line">
<span className="timestamp">{msg.timestamp.toLocaleTimeString()}</span>
<PlatformTag source="twitch" />
{msg.authorAvatar && <img src={msg.authorAvatar} alt={msg.author} className="avatar" />}
{renderTwitchIcon(msg)}
{renderTwitchBadges(msg)}
<a
href={`https://twitch.tv/${msg.author}`}
target="_blank"
rel="noopener noreferrer"
className={`author author-link ${msg.isCurrentUser ? 'self' : ''}`}
style={{
color: msg.isCurrentUser ? '#ffffff' : (msg.authorColor || '#e2e8f0'),
}}
>
{msg.author}
</a>
<span className="message-separator">:</span>
<span
className="message-text"
dangerouslySetInnerHTML={{ __html: parseMessageWithEmotes(msg.content, msg.emotes) }}
/>
<div className="message-actions">
{onReply && (
<button onClick={() => onReply(msg)} className="action-button" title="Reply to this message" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<MdReply size={12} />
</button>
)}
<button
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(msg.content).then(() => onCopyMessage?.()).catch(() => { });
}
}}
className="action-button" title="Copy message" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<MdContentCopy size={12} />
</button>
</div>
</div>
</div>
);
}
// YouTube message
const msg = entry.msg as YTMessage;
if (msg.superchat) {
return (
<div key={getKey(entry)} className="yt-superchat" style={{ backgroundColor: msg.superchat.color }}>
<div className="yt-superchat-header">
{msg.authorAvatar && (
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
)}
<div className="yt-superchat-info">
<div className="yt-superchat-author">{msg.author}</div>
<div className="yt-superchat-amount">{msg.superchat.amount}</div>
</div>
</div>
{(msg.parts.length > 0 || msg.superchat.sticker) && (
<div className="yt-superchat-body">
{msg.parts.map((part, i) =>
part.type === 'emoji' ? (
<img
key={i}
src={part.url}
alt={part.alt || part.emojiText || '🙂'}
title={part.emojiText || part.alt}
className="chat-emote"
data-tooltip-url={part.url}
data-emote-name={part.alt || part.emojiText || 'Emoji'}
style={{
width: part.isCustomEmoji ? '28px' : '18px',
height: part.isCustomEmoji ? '28px' : '18px',
verticalAlign: 'middle',
margin: '0 1px',
objectFit: 'contain',
cursor: 'pointer',
}}
/>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
)
)}
{msg.superchat.sticker && (
<img
src={msg.superchat.sticker.url}
alt={msg.superchat.sticker.alt}
className="yt-superchat-sticker"
style={{ width: '72px', height: '72px', marginTop: '4px', objectFit: 'contain' }}
/>
)}
</div>
)}
</div>
);
}
return (
<div key={getKey(entry)} className="message">
<div className="message-single-line">
<span className="timestamp">
{(msg.timestamp instanceof Date ? msg.timestamp : new Date(msg.timestamp))
.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<PlatformTag source="youtube" />
{msg.authorAvatar && (
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
)}
{renderYTBadges(msg)}
<span className="author" style={{ color: getColorFromName(msg.author) }}>
{msg.author}
</span>
<span className="message-separator">:</span>
<span className="message-text">
{msg.parts.map((part, i) =>
part.type === 'emoji' ? (
<img
key={i}
src={part.url}
alt={part.alt || part.emojiText || '🙂'}
title={part.emojiText || part.alt}
className="chat-emote"
data-tooltip-url={part.url}
data-emote-name={part.alt || part.emojiText || 'Emoji'}
style={{
width: part.isCustomEmoji ? '28px' : '18px',
height: part.isCustomEmoji ? '28px' : '18px',
verticalAlign: 'middle',
margin: '0 1px',
objectFit: 'contain',
cursor: 'pointer',
}}
/>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
)
)}
</span>
</div>
</div>
);
})}
</div>
{/* Scroll-to-bottom button */}
{!isAtBottom && (
<button
className="go-to-bottom-button"
title="Go to latest messages"
onClick={() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
setIsAtBottom(true);
setNewMessagesCount(0);
prevCountRef.current = entries.length;
}
}}
>
{newMessagesCount > 0 ? `New Messages (${newMessagesCount})` : 'New Messages'}
</button>
)}
</div>
);
}
export default memo(UnifiedChat);

View File

@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { FaPlus, FaTrash, FaEye } from 'react-icons/fa';
import {
getWatchedStreamers,
addWatchedStreamer,
removeWatchedStreamer,
} from '../lib/streamMonitor';
import type { TwitchChannel } from '../components/AppContainer';
import '../styles/WatchlistManager.css';
interface WatchlistManagerProps {
availableChannels: TwitchChannel[];
onClose: () => void;
}
export default function WatchlistManager({ availableChannels, onClose }: WatchlistManagerProps) {
const [watchedStreamers, setWatchedStreamers] = useState(getWatchedStreamers());
const [selectedChannelId, setSelectedChannelId] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const handleAddStreamer = () => {
if (!selectedChannelId) return;
const channel = availableChannels.find((c) => c.id === selectedChannelId);
if (channel) {
addWatchedStreamer({
id: channel.id,
name: channel.name,
displayName: channel.displayName,
profileImageUrl: channel.profileImageUrl,
});
setWatchedStreamers(getWatchedStreamers());
setSelectedChannelId('');
setSearchQuery('');
}
};
const handleRemoveStreamer = (streamerId: string) => {
removeWatchedStreamer(streamerId);
setWatchedStreamers(getWatchedStreamers());
};
const isAlreadyWatched = (channelId: string) =>
watchedStreamers.some((s) => s.id === channelId);
const unwatchedChannels = availableChannels.filter(
(c) => !isAlreadyWatched(c.id)
);
const filteredChannels = searchQuery.trim() === ''
? unwatchedChannels
: unwatchedChannels.filter((c) =>
c.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="watchlist-manager-overlay" onClick={onClose}>
<div className="watchlist-manager" onClick={(e) => e.stopPropagation()}>
<h2>Manage Notification Streams</h2>
<p className="subtitle">Get notifications when these streamers go live</p>
<div className="watchlist-add-section">
<input
type="text"
placeholder="Search channels..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="channel-search"
/>
<div className="channel-list-dropdown">
{filteredChannels.length === 0 ? (
<div className="no-channels-message">
{searchQuery ? 'No channels match your search' : 'All followed channels are already being watched'}
</div>
) : (
filteredChannels.map((channel) => (
<button
key={channel.id}
className="channel-list-item"
onClick={() => {
setSelectedChannelId(channel.id);
setSearchQuery('');
}}
>
{channel.profileImageUrl && (
<img src={channel.profileImageUrl} alt="" className="channel-thumb" />
)}
<div className="channel-info">
<div className="channel-display-name">{channel.displayName}</div>
<div className="channel-username">@{channel.name}</div>
</div>
</button>
))
)}
</div>
{selectedChannelId && (
<div className="selected-channel-display">
{unwatchedChannels.find((c) => c.id === selectedChannelId)?.displayName}
</div>
)}
<button
className="add-button"
onClick={handleAddStreamer}
disabled={!selectedChannelId}
>
<FaPlus /> Add
</button>
</div>
<div className="watchlist-items">
{watchedStreamers.length === 0 ? (
<p className="empty-message">No streamers being watched yet</p>
) : (
watchedStreamers.map((streamer) => (
<div key={streamer.id} className="watchlist-item">
<div className="item-info">
{streamer.profileImageUrl && (
<img
src={streamer.profileImageUrl}
alt={streamer.displayName}
className="item-avatar"
/>
)}
<div className="item-details">
<div className="item-name">{streamer.displayName}</div>
<div className="item-username">@{streamer.name}</div>
</div>
</div>
<button
className="remove-button"
onClick={() => handleRemoveStreamer(streamer.id)}
title="Remove from watchlist"
>
<FaTrash />
</button>
</div>
))
)}
</div>
<div className="watchlist-footer">
<p className="info-text">
<FaEye style={{ marginRight: '6px' }} />
You'll be notified once per session when each streamer goes live
</p>
<button className="close-button" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,305 @@
import React, { useEffect, useRef, useState } from 'react';
import type { PlatformSession } from '../lib/types';
import { AiOutlineLoading3Quarters } from 'react-icons/ai';
interface YouTubeEmote {
/** The emojiText code e.g. ":slightly_smiling_face:" or the alt text */
name: string;
url: string;
isCustom: boolean;
}
interface YouTubeChatProps {
videoId: string;
accessToken: string;
session: PlatformSession;
/** Called whenever new emotes are discovered in the chat stream */
onEmotesDiscovered?: (emotes: YouTubeEmote[]) => void;
/** Called whenever the message list updates (for unified chat mode) */
onMessages?: (messages: ChatMessage[]) => void;
fontSize?: number;
}
export type { YouTubeEmote };
interface MessagePart {
type: 'text' | 'emoji';
text?: string;
url?: string;
alt?: string;
emojiText?: string;
isCustomEmoji?: boolean;
}
interface ChatMessage {
id: string;
author: string;
authorAvatar?: string;
badges?: Record<string, string>;
parts: MessagePart[];
timestamp: Date;
authorChannelId?: string;
superchat?: {
amount: string;
color: string;
sticker?: {
url: string;
alt: string;
};
};
}
// Turns a username into a stable color so each author always looks the same
function getColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash) + name.charCodeAt(i);
hash = hash & hash;
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 55%)`;
}
export default function YouTubeChat({ videoId, accessToken, session, onEmotesDiscovered, onMessages, fontSize = 14 }: YouTubeChatProps) {
const containerRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastMessageCountRef = useRef(0);
// Remembers how many emotes we've already reported so we don't spam the parent
const lastEmoteCountRef = useRef(0);
// Keep messages fresh by polling our backend every couple of seconds
useEffect(() => {
if (!videoId) return;
let isMounted = true;
const fetchChatMessages = async () => {
try {
setError(null);
const res = await fetch(`/api/youtube-stream-chat?videoId=${encodeURIComponent(videoId)}`);
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
console.error('Chat API error:', errorData);
throw new Error(errorData.error || `HTTP ${res.status}`);
}
const data = await res.json();
if (!isMounted) return;
if (data.success && data.messages) {
const fetchedMessages = (data.messages as any[]).map((msg: any) => ({
id: msg.id,
author: msg.author,
authorAvatar: msg.authorAvatar,
badges: msg.badges,
parts: (msg.parts || []) as MessagePart[],
timestamp: new Date(msg.timestamp),
authorChannelId: msg.authorChannelId,
superchat: msg.superchat,
}));
// Deduplicate just in case the API sends anything twice
const uniqueMessagesMap = new Map();
fetchedMessages.forEach(m => uniqueMessagesMap.set(m.id, m));
const newMessages = Array.from(uniqueMessagesMap.values());
// Let the parent know about any newly spotted emotes (for autocomplete)
const currentEmoteCount = Array.isArray(data.emotes) ? data.emotes.length : 0;
if (onEmotesDiscovered && currentEmoteCount !== lastEmoteCountRef.current) {
lastEmoteCountRef.current = currentEmoteCount;
onEmotesDiscovered(
(data.emotes as any[]).map((e) => ({
name: e.name,
url: e.url,
isCustom: !!e.isCustomEmoji,
}))
);
}
// Compare the last message ID (not just count) to catch the edge case where
// one message rolls off while another comes in, keeping the count identical.
const lastNewId = newMessages.length > 0 ? newMessages[newMessages.length - 1].id : null;
const lastCurrentId = messages.length > 0 ? messages[messages.length - 1].id : null;
if (newMessages.length !== messages.length || lastNewId !== lastCurrentId) {
setMessages(newMessages);
lastMessageCountRef.current = newMessages.length;
// Set as connected once we get the first successful response
if (!isConnected) {
setIsConnected(true);
setIsLoading(false);
}
} else if (!isConnected) {
// Even an empty response counts as a successful connection
setIsConnected(true);
setIsLoading(false);
}
}
} catch (err) {
if (isMounted) {
console.error('Error fetching YouTube chat:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch messages');
setIsLoading(false);
}
}
};
// Kick off the first fetch right away, then repeat every 2 s
fetchChatMessages();
pollingIntervalRef.current = setInterval(fetchChatMessages, 2000);
return () => {
isMounted = false;
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, [videoId, isConnected]);
// Keep the view pinned to the newest message
useEffect(() => {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
}
}, [messages]);
// Pass the current message list up so the parent can use it in unified chat
useEffect(() => {
if (onMessages) {
onMessages(messages);
}
}, [messages, onMessages]);
if (isLoading) {
return (
<div className="yt-chat-status">
<AiOutlineLoading3Quarters className="animate-spin" /> Connecting to YouTube chat...
</div>
);
}
if (error) {
return (
<div className="yt-chat-status yt-chat-error">
<div> {error}</div>
<div className="yt-chat-error-sub">You can still send messages below</div>
</div>
);
}
return (
<div ref={containerRef} className="yt-chat-outer">
<div
ref={messagesContainerRef}
className="yt-chat-container"
style={{ '--chat-font-size': `${fontSize}px` } as React.CSSProperties}
>
{!isConnected && (
<div className="yt-chat-placeholder">
Connecting to YouTube chat...
</div>
)}
{isConnected && messages.length === 0 && (
<div className="yt-chat-placeholder">
Waiting for messages...
</div>
)}
{messages.map((msg) => {
if (msg.superchat) {
return (
<div key={msg.id} className="yt-superchat" style={{ backgroundColor: msg.superchat.color }}>
<div className="yt-superchat-header">
{msg.authorAvatar && (
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
)}
<div className="yt-superchat-info">
<div className="yt-superchat-author">{msg.author}</div>
<div className="yt-superchat-amount">{msg.superchat.amount}</div>
</div>
</div>
{(msg.parts.length > 0 || msg.superchat.sticker) && (
<div className="yt-superchat-body">
{msg.parts.map((part, i) =>
part.type === 'emoji' ? (
<img
key={i}
src={part.url}
alt={part.alt || part.emojiText || '🙂'}
title={part.emojiText || part.alt}
className={`yt-emoji ${part.isCustomEmoji ? 'custom' : 'standard'}`}
/>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
)
)}
{msg.superchat.sticker && (
<img
src={msg.superchat.sticker.url}
alt={msg.superchat.sticker.alt}
className="yt-superchat-sticker"
/>
)}
</div>
)}
</div>
);
}
return (
<div key={msg.id} className="message">
<div className="message-single-line">
<span className="timestamp">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{msg.authorAvatar && (
<img src={msg.authorAvatar} alt={msg.author} className="avatar" />
)}
{msg.badges && Object.keys(msg.badges).length > 0 && (
<div className="yt-message-badges">
{Object.entries(msg.badges).map(([name, url]) => (
<img
key={name}
src={url}
alt={name}
className="badge yt-badge"
title={name}
/>
))}
</div>
)}
<span className="author" style={{ color: getColorFromName(msg.author) }}>
{msg.author}
</span>
<span className="message-separator">:</span>
<span className="message-text">
{msg.parts.map((part, i) =>
part.type === 'emoji' ? (
<img
key={i}
src={part.url}
alt={part.alt || part.emojiText || '🙂'}
title={part.emojiText || part.alt}
className={`yt-emoji ${part.isCustomEmoji ? 'custom' : 'standard'}`}
/>
) : (
<React.Fragment key={i}>{part.text}</React.Fragment>
)
)}
</span>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import { FaPlus, FaTrash, FaYoutube } from 'react-icons/fa';
import {
getYoutubeLinks,
addYoutubeLink,
removeYoutubeLink,
} from '../lib/youtubeLinks';
import type { YouTubeLink } from '../lib/youtubeLinks';
import type { TwitchChannel } from '../components/AppContainer';
import '../styles/YouTubeLinker.css';
interface YouTubeLinkerProps {
availableChannels: TwitchChannel[];
onClose: () => void;
onLinksChanged?: () => void;
}
export default function YouTubeLinker({ availableChannels, onClose, onLinksChanged }: YouTubeLinkerProps) {
const [youtubeLinks, setYoutubeLinks] = useState<YouTubeLink[]>(getYoutubeLinks());
const [selectedChannelId, setSelectedChannelId] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const [youtubeUrl, setYoutubeUrl] = useState<string>('');
const [error, setError] = useState<string>('');
const handleAddLink = () => {
if (!selectedChannelId || !youtubeUrl.trim()) return;
// Validate URL format
const trimmed = youtubeUrl.trim();
if (!trimmed.startsWith('https://www.youtube.com/') && !trimmed.startsWith('https://youtube.com/') && !trimmed.startsWith('http://www.youtube.com/') && !trimmed.startsWith('http://youtube.com/')) {
setError('Please enter a valid YouTube channel URL (e.g. https://www.youtube.com/@channelname)');
return;
}
const channel = availableChannels.find((c) => c.id === selectedChannelId);
if (channel) {
addYoutubeLink({
twitchChannelId: channel.id,
twitchDisplayName: channel.displayName,
youtubeUrl: trimmed,
});
setYoutubeLinks(getYoutubeLinks());
setSelectedChannelId('');
setSearchQuery('');
setYoutubeUrl('');
setError('');
onLinksChanged?.();
}
};
const handleRemoveLink = (twitchChannelId: string) => {
removeYoutubeLink(twitchChannelId);
setYoutubeLinks(getYoutubeLinks());
onLinksChanged?.();
};
const isAlreadyLinked = (channelId: string) =>
youtubeLinks.some((l) => l.twitchChannelId === channelId);
const unlinkedChannels = availableChannels.filter(
(c) => !isAlreadyLinked(c.id)
);
const filteredChannels = searchQuery.trim() === ''
? unlinkedChannels
: unlinkedChannels.filter((c) =>
c.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="ytlinker-overlay" onClick={onClose}>
<div className="ytlinker-manager" onClick={(e) => e.stopPropagation()}>
<h2>
<FaYoutube style={{ color: '#FF0000', marginRight: '8px', verticalAlign: 'middle' }} />
Link YouTube Channels
</h2>
<p className="ytlinker-subtitle">Link a YouTube channel to a followed streamer to see their YouTube live chat</p>
<div className="ytlinker-add-section">
<div className="ytlinker-inputs">
<div className="ytlinker-channel-select">
<label className="ytlinker-label">Twitch Channel</label>
<input
type="text"
placeholder="Search followed channels..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedChannelId('');
}}
className="ytlinker-search"
/>
<div className="ytlinker-dropdown">
{filteredChannels.length === 0 ? (
<div className="ytlinker-no-channels">
{searchQuery ? 'No channels match your search' : 'All followed channels already have a YouTube link'}
</div>
) : (
filteredChannels.map((channel) => (
<button
key={channel.id}
className="ytlinker-channel-item"
onClick={() => {
setSelectedChannelId(channel.id);
setSearchQuery(channel.displayName);
}}
>
{channel.profileImageUrl && (
<img src={channel.profileImageUrl} alt="" className="item-avatar" />
)}
<div className="ytlinker-channel-info">
<div className="ytlinker-channel-display-name">{channel.displayName}</div>
<div className="ytlinker-channel-username">@{channel.name}</div>
</div>
</button>
))
)}
</div>
{selectedChannelId && (
<div className="ytlinker-selected-display">
{unlinkedChannels.find((c) => c.id === selectedChannelId)?.displayName || searchQuery}
</div>
)}
</div>
<div className="ytlinker-url-input">
<label className="ytlinker-label">YouTube Channel URL</label>
<input
type="text"
placeholder="https://www.youtube.com/@channelname"
value={youtubeUrl}
onChange={(e) => {
setYoutubeUrl(e.target.value);
setError('');
}}
className="ytlinker-search"
/>
</div>
</div>
{error && <div className="ytlinker-error">{error}</div>}
<button
className="ytlinker-add-button"
onClick={handleAddLink}
disabled={!selectedChannelId || !youtubeUrl.trim()}
>
<FaPlus /> Link
</button>
</div>
<div className="ytlinker-items">
{youtubeLinks.length === 0 ? (
<p className="ytlinker-empty">No YouTube channels linked yet</p>
) : (
youtubeLinks.map((link) => {
const channel = availableChannels.find((c) => c.id === link.twitchChannelId);
return (
<div key={link.twitchChannelId} className="ytlinker-item">
<div className="ytlinker-item-info">
{channel?.profileImageUrl && (
<img src={channel.profileImageUrl} alt="" className="item-avatar" />
)}
<div className="ytlinker-item-details">
<div className="ytlinker-item-name">{link.twitchDisplayName}</div>
<a
href={link.youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="ytlinker-item-url"
>
{link.youtubeUrl}
</a>
</div>
</div>
<button
className="ytlinker-remove-button"
onClick={() => handleRemoveLink(link.twitchChannelId)}
title="Remove YouTube link"
>
<FaTrash />
</button>
</div>
);
})
)}
</div>
<div className="ytlinker-footer">
<p className="ytlinker-info-text">
<FaYoutube style={{ marginRight: '6px', color: '#FF0000' }} />
When viewing a linked channel, YouTube live chat will appear above the Twitch chat
</p>
<button className="ytlinker-close-button" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@@ -0,0 +1,90 @@
import { useState, useEffect, useCallback } from 'react';
import {
checkLiveStreamers,
getWatchedStreamers,
markStreamerNotified,
markStreamerOffline,
getNotifiedStreamers,
} from '../lib/streamMonitor';
import type { LiveStreamer } from '../lib/streamMonitor';
import type { PlatformSession } from '../lib/types';
interface UseStreamMonitorOptions {
session: PlatformSession | undefined;
clientId: string;
enabled?: boolean;
checkInterval?: number; // in milliseconds, default 60000 (1 minute)
}
export function useStreamMonitor({
session,
clientId,
enabled = true,
checkInterval = 60000,
}: UseStreamMonitorOptions) {
const [liveStreamers, setLiveStreamers] = useState<LiveStreamer[]>([]);
const [newLiveStreamer, setNewLiveStreamer] = useState<LiveStreamer | null>(null);
const [isChecking, setIsChecking] = useState(false);
const checkStreamers = useCallback(async () => {
if (!session || !enabled) return;
setIsChecking(true);
try {
const watchedStreamers = getWatchedStreamers();
const nowLive = await checkLiveStreamers(watchedStreamers, session.accessToken, clientId);
const notifiedStreamers = getNotifiedStreamers();
// Check for newly live streamers
const newlyLive = nowLive.filter((streamer) => !notifiedStreamers.has(streamer.id));
if (newlyLive.length > 0) {
// Notify about the first newly live streamer
const firstNewStreamer = newlyLive[0];
setNewLiveStreamer(firstNewStreamer);
markStreamerNotified(firstNewStreamer.id);
}
// Check for streamers that went offline
for (const streamerId of notifiedStreamers) {
if (!nowLive.find((s) => s.id === streamerId)) {
markStreamerOffline(streamerId);
}
}
setLiveStreamers(nowLive);
} catch (error) {
console.error('Error monitoring streams:', error);
} finally {
setIsChecking(false);
}
}, [session, clientId, enabled]);
// Check on mount and then periodically
useEffect(() => {
if (!enabled || !session) {
setNewLiveStreamer(null);
return;
}
// Check immediately
checkStreamers();
// Set up interval
const interval = setInterval(checkStreamers, checkInterval);
return () => clearInterval(interval);
}, [checkStreamers, enabled, session, checkInterval]);
const clearNewNotification = useCallback(() => {
setNewLiveStreamer(null);
}, []);
return {
liveStreamers,
newLiveStreamer,
isChecking,
clearNewNotification,
};
}

View File

@@ -0,0 +1,158 @@
import { useEffect, useRef, useCallback } from 'react';
import type { PlatformSession } from '../lib/types';
interface TokenRefreshOptions {
warningThreshold?: number; // ms before expiry to start refresh (default: 5 min)
checkInterval?: number; // ms between checks (default: 1 min)
maxRetries?: number; // max refresh attempts (default: 3)
}
const DEFAULT_WARNING_THRESHOLD = 5 * 60 * 1000; // 5 minutes
const DEFAULT_CHECK_INTERVAL = 60 * 1000; // 1 minute
const DEFAULT_MAX_RETRIES = 3;
/**
* Custom hook for automatic Twitch token refresh with retry logic
* Monitors token expiration and proactively refreshes before expiry
*/
export const useTokenRefresh = (
session: PlatformSession | null,
onSessionUpdate: (session: PlatformSession) => void,
onRefreshError: (error: Error) => void,
options: TokenRefreshOptions = {}
) => {
const {
warningThreshold = DEFAULT_WARNING_THRESHOLD,
checkInterval = DEFAULT_CHECK_INTERVAL,
maxRetries = DEFAULT_MAX_RETRIES,
} = options;
const intervalRef = useRef<NodeJS.Timeout>();
const retryCountRef = useRef(0);
const isRefreshingRef = useRef(false);
const refreshToken = useCallback(async () => {
// We need at least a session to refresh. The actual refresh token is read
// from the httpOnly cookie server-side, so session.refreshToken is optional.
if (!session || isRefreshingRef.current) {
return;
}
isRefreshingRef.current = true;
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
// Ensure httpOnly auth cookies are sent with the request so the
// server-side refresh endpoint can read the refresh token.
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
// Only send the platform — the server reads the refresh token
// from the httpOnly cookie, not from client-supplied data.
body: JSON.stringify({
platform: session.platform,
}),
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`);
}
const newTokenData = await response.json();
const now = new Date().getTime();
const updatedSession = {
...session,
accessToken: newTokenData.accessToken,
refreshToken: newTokenData.refreshToken || session.refreshToken,
expiresAt: now + ((newTokenData.expiresIn || 3600) * 1000),
};
onSessionUpdate(updatedSession);
retryCountRef.current = 0; // Reset retry count on success
console.log('✓ Token refreshed successfully', {
expiresIn: newTokenData.expiresIn,
expiresAt: new Date(updatedSession.expiresAt).toISOString(),
});
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
console.error('✗ Token refresh failed:', err.message);
retryCountRef.current += 1;
if (retryCountRef.current >= maxRetries) {
console.error(`Token refresh failed after ${maxRetries} retries. Logging out.`);
onRefreshError(err);
retryCountRef.current = 0;
}
} finally {
isRefreshingRef.current = false;
}
}, [session, onSessionUpdate, onRefreshError, maxRetries]);
const checkAndRefresh = useCallback(() => {
// Require at least an expiresAt to know we have a real session.
// We do NOT require session.refreshToken here because the server reads it
// from the httpOnly cookie — the client-side refreshToken is optional.
if (!session?.expiresAt) {
return;
}
const now = new Date().getTime();
const timeUntilExpiry = session.expiresAt - now;
// Also refresh immediately when the accessToken is absent (e.g. on page
// reload — we no longer persist it to localStorage so it starts undefined).
const tokenMissing = !session.accessToken;
if (tokenMissing || timeUntilExpiry < warningThreshold) {
if (tokenMissing) {
console.log('Access token missing (page reload), refreshing immediately...');
} else {
console.log(`Token expiring in ${Math.round(timeUntilExpiry / 1000)}s, refreshing...`);
}
refreshToken();
}
}, [session, warningThreshold, refreshToken]);
useEffect(() => {
// We need at least a session with expiresAt to schedule refreshes.
// The actual refresh token lives in the httpOnly cookie on the server.
if (!session?.expiresAt) {
// Clear interval if no session
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = undefined;
}
return;
}
// Initial check
checkAndRefresh();
// Set up periodic check
intervalRef.current = setInterval(checkAndRefresh, checkInterval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [session, checkInterval, checkAndRefresh]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return {
refreshToken,
isRefreshing: isRefreshingRef.current,
retryCount: retryCountRef.current,
};
};

382
src/hooks/useTwitchChat.ts Normal file
View File

@@ -0,0 +1,382 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { StaticAuthProvider } from '@twurple/auth';
import { ChatClient } from '@twurple/chat';
import { getBadge, getBadgeDetail } from '../lib/badges';
import type { ChatMessage } from '../lib/types';
// Cache for user info - limit to 500 entries for memory efficiency
const userInfoCache = new Map<string, { displayName: string; profileImageUrl: string | null }>();
const MAX_CACHE_SIZE = 500;
const NOTIFICATION_AUDIO_VOLUME = 0.3;
function setUserInfoCache(userId: string, info: { displayName: string; profileImageUrl: string | null }) {
// Limit cache size to prevent memory leaks
if (userInfoCache.size >= MAX_CACHE_SIZE) {
const firstKey = userInfoCache.keys().next().value;
if (firstKey !== undefined) {
userInfoCache.delete(firstKey);
}
}
userInfoCache.set(userId, info);
}
// Async function to get user info
async function getUserInfo(userId: string, accessToken: string) {
if (userInfoCache.has(userId)) {
return userInfoCache.get(userId);
}
try {
const response = await fetch(`/api/user-info?userId=${userId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.ok) {
const data = await response.json();
setUserInfoCache(userId, data);
return data;
}
} catch (error) {
console.error('Failed to fetch user info:', error);
}
return null;
}
export function useTwitchChat(
channelName: string,
accessToken: string,
username: string,
onMessage: (message: ChatMessage) => void,
enabled: boolean = true,
mentionSoundEnabled: boolean = true
) {
const [isConnected, setIsConnected] = useState(false);
const [client, setClient] = useState<ChatClient | null>(null);
// Retrieve Client ID from env; it's required for twurple's AuthProvider
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
useEffect(() => {
if (!enabled) return;
if (!channelName || !accessToken || !username || !clientId) {
if (enabled && !clientId) {
console.error('Twitch Client ID is missing (PUBLIC_TWITCH_CLIENT_ID env var)');
}
return;
}
let active = true;
// Use StaticAuthProvider for client-side chat
const authProvider = new StaticAuthProvider(clientId, accessToken);
// Initialize the Twurple ChatClient
const newClient = new ChatClient({
authProvider,
channels: [channelName],
// reconnect handled by default in twurple
});
newClient.onConnect(() => {
if (!active) return;
console.log(`Connected to ${channelName}`);
setIsConnected(true);
});
newClient.onDisconnect(() => {
if (!active) return;
console.log('Disconnected from Twitch chat');
setIsConnected(false);
});
newClient.onMessage((channel, user, message, msg) => {
if (!active) return;
const replyInfo = msg.parentMessageId ? {
id: msg.parentMessageId,
author: msg.parentMessageUserDisplayName || msg.parentMessageUserName || '',
content: msg.parentMessageText || '',
} : undefined;
const mentionsUser = (
message.toLowerCase().includes(`@${username.toLowerCase()}`) ||
(replyInfo?.author.toLowerCase() === username.toLowerCase())
);
// Map Twitch emotes - Twurple provides emote data in msg.emotes
const twitchEmotes: Record<string, string[]> = {};
msg.emoteOffsets.forEach((offsets, emoteId) => {
twitchEmotes[emoteId] = offsets;
});
const isBot = msg.userInfo.badges.has('bot');
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const cachedUserInfo = msg.userInfo.userId ? userInfoCache.get(msg.userInfo.userId) : undefined;
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: msg.isCheer ? `${message} [${msg.bits} bits]` : message,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
authorAvatar: cachedUserInfo?.profileImageUrl || undefined,
replyTo: replyInfo,
mentionsUser,
emotes: twitchEmotes,
isBot,
badges: badgeImages,
badgeDetails,
isCurrentUser: msg.userInfo.userName.toLowerCase() === username.toLowerCase(),
messageType: msg.isRedemption ? 'redemption' : (msg.isCheer ? 'cheer' : 'chat'),
rewardId: msg.isRedemption ? (msg.rewardId || undefined) : undefined,
};
if (!cachedUserInfo && msg.userInfo.userId) {
getUserInfo(msg.userInfo.userId, accessToken).then((userInfo) => {
if (userInfo?.profileImageUrl) {
onMessage({
...chatMessage,
authorAvatar: userInfo.profileImageUrl,
});
return;
}
onMessage(chatMessage);
}).catch(() => {
onMessage(chatMessage);
});
} else {
onMessage(chatMessage);
}
if (mentionsUser && mentionSoundEnabled) {
try {
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBTGH0fPTgjMGG2S36+OZSgwOUKXh8bllHgU2jtXywH0pBSl+zPLaizsIGGS56+idTgwNU6rm8bllHAU5kdXx');
audio.volume = NOTIFICATION_AUDIO_VOLUME;
audio.play().catch(() => { });
} catch (e) { }
}
});
newClient.onAction((channel, user, message, msg) => {
if (!active) return;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const cachedUserInfo = msg.userInfo.userId ? userInfoCache.get(msg.userInfo.userId) : undefined;
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: message,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
authorAvatar: cachedUserInfo?.profileImageUrl || undefined,
mentionsUser: false,
emotes: {},
isBot: msg.userInfo.badges.has('bot'),
badges: badgeImages,
badgeDetails,
isCurrentUser: msg.userInfo.userName.toLowerCase() === username.toLowerCase(),
messageType: 'chat',
};
onMessage(chatMessage);
});
newClient.onSub((channel, user, subInfo, msg) => {
if (!active) return;
const tier = subInfo.plan === 'Prime' ? 'Prime' : subInfo.plan === '3000' ? '3' : subInfo.plan === '2000' ? '2' : '1';
const subMessage = `subscribed with ${subInfo.plan === 'Prime' ? 'Prime Gaming' : `a Tier ${tier} subscription`}${subInfo.message ? ` - ${subInfo.message}` : ''}`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: subMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'subscription',
};
onMessage(chatMessage);
});
newClient.onResub((channel, user, subInfo, msg) => {
if (!active) return;
const monthsCount = subInfo.months;
const monthLabel = monthsCount === 1 ? 'month' : 'months';
const resubMessage = `resubscribed for ${monthsCount} ${monthLabel}${subInfo.message ? ` - ${subInfo.message}` : ''}`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: resubMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'resub',
};
onMessage(chatMessage);
});
newClient.onSubGift((channel, recipient, subInfo, msg) => {
if (!active) return;
const tier = subInfo.plan === '3000' ? '3' : subInfo.plan === '2000' ? '2' : '1';
const giftMessage = `gifted a Tier ${tier} subscription to ${recipient}`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: giftMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'subgift',
};
onMessage(chatMessage);
});
// Handle community gifted subs (mass gifts)
newClient.onCommunitySub((channel, user, giftInfo, msg) => {
if (!active) return;
const tier = giftInfo.plan === '3000' ? '3' : giftInfo.plan === '2000' ? '2' : '1';
const giftMessage = `gifted ${giftInfo.count} Tier ${tier} subscriptions to the community!`;
const badgeImages: Record<string, string> = {};
const badgeDetails: Record<string, { url: string; title: string }> = {};
msg.userInfo.badges.forEach((version, badgeName) => {
const detail = getBadgeDetail(badgeName, version);
if (detail) {
badgeImages[badgeName] = detail.url;
badgeDetails[badgeName] = { url: detail.url, title: detail.title };
}
});
const chatMessage: ChatMessage = {
id: msg.id,
platform: 'twitch',
author: msg.userInfo.displayName || msg.userInfo.userName || 'Anonymous',
content: giftMessage,
timestamp: msg.date,
userId: msg.userInfo.userId,
authorColor: msg.userInfo.color || '#FFFFFF',
mentionsUser: false,
emotes: {},
isBot: false,
badges: badgeImages,
badgeDetails,
messageType: 'subgift',
};
onMessage(chatMessage);
});
newClient.connect();
setClient(newClient);
return () => {
active = false;
if (newClient) {
newClient.quit();
}
};
}, [channelName, accessToken, username, onMessage, enabled, clientId]);
const sendMessage = useCallback(
async (message: string, replyToMessageId?: string) => {
if (!client || !isConnected) {
throw new Error('Not connected to chat');
}
try {
if (replyToMessageId) {
// Twurple supports replies natively
await client.say(channelName, message, { replyTo: replyToMessageId });
} else {
await client.say(channelName, message);
}
} catch (error) {
console.error('Failed to send message:', error);
throw error;
}
},
[client, isConnected, channelName]
);
return { isConnected, sendMessage };
}

76
src/lib/badges.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ApiClient } from '@twurple/api';
import { StaticAuthProvider } from '@twurple/auth';
import type { HelixChatBadgeSet, HelixChatBadgeVersion } from '@twurple/api';
// Shorthand for badge data we actually care about in the UI
export interface BadgeDetail {
id: string;
version: string;
url: string;
title: string;
description: string;
}
// Store the full HelixChatBadgeSet objects for better metadata
let badgesGlobal: Map<string, HelixChatBadgeSet> = new Map();
let badgesChannel: Map<string, HelixChatBadgeSet> = new Map();
// Wipe everything if we switch accounts or reset
export function clearBadgeCache(): void {
badgesGlobal.clear();
badgesChannel.clear();
}
// Grab badge sets from Twitch. If channelId is null, gets global ones.
async function fetchBadges(apiClient: ApiClient, channelId?: string): Promise<Map<string, HelixChatBadgeSet>> {
try {
const badgeSets = channelId
? await apiClient.chat.getChannelBadges(channelId)
: await apiClient.chat.getGlobalBadges();
const map = new Map<string, HelixChatBadgeSet>();
badgeSets.forEach(set => map.set(set.id, set));
return map;
} catch (error) {
console.warn(`Twitch ${channelId ? 'channel' : 'global'} badges not loaded:`, error);
return new Map();
}
}
// Pull down both global and channel-specific badges so we can show them in chat
export async function initializeBadges(channelId: string, accessToken: string, clientId: string): Promise<void> {
const authProvider = new StaticAuthProvider(clientId, accessToken);
const apiClient = new ApiClient({ authProvider });
const [global, channel] = await Promise.all([
fetchBadges(apiClient),
fetchBadges(apiClient, channelId),
]);
badgesGlobal = global;
badgesChannel = channel;
}
// Get the raw image URL for a specific badge/version
export function getBadge(name: string, version: string): string | undefined {
const detail = getBadgeDetail(name, version);
return detail?.url;
}
// Find the full badge info, checking the channel first (custom badges) then global ones
export function getBadgeDetail(name: string, version: string): BadgeDetail | undefined {
// Look in channel badges first, then global
const set = badgesChannel.get(name) || badgesGlobal.get(name);
if (!set) return undefined;
const v = set.getVersion(version);
if (!v) return undefined;
return {
id: name,
version: version,
url: v.getImageUrl(2) || v.getImageUrl(1), // Default to 2x for better appearance
title: v.title,
description: v.description
};
}

777
src/lib/emotes.ts Normal file
View File

@@ -0,0 +1,777 @@
// Emote sources: 7TV, BTTV, FFZ
import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify";
/**
* DOMPurify configuration — only the elements and attributes our emote
* renderer ever emits are permitted. Everything else is stripped.
*/
const DOMPURIFY_CONFIG: DOMPurifyConfig = {
ALLOWED_TAGS: ["img", "a", "strong", "span"],
ALLOWED_ATTR: [
// <img>
"src", "alt", "title", "class", "style", "loading",
"data-emote-name", "data-tooltip-url",
// <a>
"href", "target", "rel",
],
ALLOW_DATA_ATTR: false,
FORCE_BODY: false,
};
const EMOTE_SIZE = "2";
const CACHE_DURATION_DAYS = 7;
const MS_PER_DAY = 1000 * 60 * 60 * 24;
const CACHE_DURATION = CACHE_DURATION_DAYS * MS_PER_DAY;
const EMOTE_BATCH_SIZE = 100;
const MAX_REPLACEMENTS = 1000;
interface EmoteMap {
[key: string]: string;
}
interface EmoteCache {
emotes: EmoteMap;
timestamp: number;
}
let emotes7tv: EmoteMap = {};
let emotesBttv: EmoteMap = {};
let emotesFfz: EmoteMap = {};
let emotesTwitch: EmoteMap = {};
/**
* Get cached emotes if valid
*/
function getCachedEmotes(cacheKey: string): EmoteMap | null {
try {
const cached = localStorage.getItem(cacheKey);
if (!cached) return null;
const cacheData: EmoteCache = JSON.parse(cached);
const isExpired = Date.now() - cacheData.timestamp > CACHE_DURATION;
if (isExpired) {
localStorage.removeItem(cacheKey);
return null;
}
return cacheData.emotes;
} catch {
return null;
}
}
/**
* Save emotes to cache
*/
function setCachedEmotes(cacheKey: string, emotes: EmoteMap): void {
try {
const cacheData: EmoteCache = {
emotes,
timestamp: Date.now(),
};
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
} catch (error) {
console.warn("Failed to cache emotes:", error);
}
}
/**
* Clear all emote caches
*/
export function clearEmoteCache(): void {
const cacheKeys = ["emotes_twitch_global"];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("emotes_")) {
localStorage.removeItem(key);
}
}
emotes7tv = {};
emotesBttv = {};
emotesFfz = {};
emotesTwitch = {};
}
/**
* Fetch 7TV global emotes
*/
async function fetch7TVGlobalEmotes(): Promise<EmoteMap> {
const cacheKey = "emotes_7tv_global";
const cached = getCachedEmotes(cacheKey);
if (cached) {
console.log('Using cached 7TV global emotes, count:', Object.keys(cached).length);
return cached;
}
try {
console.log('Fetching 7TV global emotes from 7tv.io');
const response = await fetch("https://7tv.io/v3/emote-sets/global");
const data = await response.json();
const emotes: EmoteMap = {};
if (data.emotes) {
data.emotes.forEach((emote: any) => {
emotes[emote.name] =
`https://cdn.7tv.app/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
});
}
setCachedEmotes(cacheKey, emotes);
console.log('Fetched 7TV global emotes, count:', Object.keys(emotes).length);
return emotes;
} catch (error) {
console.warn("7TV global emotes not loaded", error);
return {};
}
}
/**
* Fetch 7TV emotes for a given channel
*/
async function fetch7TVEmotes(channelId: string): Promise<EmoteMap> {
const cacheKey = `emotes_7tv_${channelId}`;
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
const response = await fetch(`https://7tv.io/v3/users/twitch/${channelId}`);
const data = await response.json();
const emotes: EmoteMap = {};
if (data.emote_set?.emotes) {
data.emote_set.emotes.forEach((emote: any) => {
emotes[emote.name] =
`https://cdn.7tv.app/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
});
}
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("7TV emotes not loaded", error);
return {};
}
}
/**
* Fetch BTTV global emotes
*/
async function fetchBTTVGlobalEmotes(): Promise<EmoteMap> {
const cacheKey = "emotes_bttv_global";
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
const response = await fetch(
"https://api.betterttv.net/3/cached/emotes/global",
);
const data = await response.json();
const emotes: EmoteMap = {};
data.forEach((emote: any) => {
emotes[emote.code] =
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
});
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("BTTV global emotes not loaded", error);
return {};
}
}
/**
* Fetch BTTV emotes for a given channel
*/
async function fetchBTTVEmotes(channelId: string): Promise<EmoteMap> {
const cacheKey = `emotes_bttv_${channelId}`;
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
const response = await fetch(
`https://api.betterttv.net/3/cached/users/twitch/${channelId}`,
);
const data = await response.json();
const emotes: EmoteMap = {};
if (data.channelEmotes) {
data.channelEmotes.forEach((emote: any) => {
emotes[emote.code] =
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
});
}
if (data.sharedEmotes) {
data.sharedEmotes.forEach((emote: any) => {
emotes[emote.code] =
`https://cdn.betterttv.net/emote/${emote.id}/${EMOTE_SIZE}x.webp`;
});
}
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("BTTV emotes not loaded", error);
return {};
}
}
/**
* Fetch FFZ global emotes
*/
async function fetchFFZGlobalEmotes(): Promise<EmoteMap> {
const cacheKey = "emotes_ffz_global";
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
const response = await fetch("https://api.frankerfacez.com/v1/set/global");
const data = await response.json();
const emotes: EmoteMap = {};
if (data.sets) {
Object.values(data.sets).forEach((set: any) => {
if (set.emoticons) {
set.emoticons.forEach((emote: any) => {
emotes[emote.name] =
`https://cdn.frankerfacez.com/emote/${emote.id}/${EMOTE_SIZE}`;
});
}
});
}
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("FFZ global emotes not loaded", error);
return {};
}
}
/**
* Fetch FFZ emotes for a given channel
*/
async function fetchFFZEmotes(channelName: string): Promise<EmoteMap> {
const cacheKey = `emotes_ffz_${channelName}`;
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
const response = await fetch(
`https://api.frankerfacez.com/v1/room/${channelName}`,
);
const data = await response.json();
const emotes: EmoteMap = {};
if (data.sets) {
const setId = data.room.set;
if (data.sets[setId]) {
data.sets[setId].emoticons.forEach((emote: any) => {
emotes[emote.name] =
`https://cdn.frankerfacez.com/emote/${emote.id}/${EMOTE_SIZE}`;
});
}
}
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("FFZ emotes not loaded", error);
return {};
}
}
/**
* Fetch Twitch global emotes
*/
async function fetchTwitchGlobalEmotes(): Promise<EmoteMap> {
const cacheKey = "emotes_twitch_global";
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
const response = await fetch("/api/twitch-proxy?path=/helix/chat/emotes/global");
const data = await response.json();
const emotes: EmoteMap = {};
if (data.data) {
data.data.forEach((emote: any) => {
const url = emote.images.url_1x.replace("/1.0", "/2.0");
emotes[emote.name] = url;
});
}
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("Twitch global emotes not loaded", error);
return {};
}
}
/**
* Fetch Twitch channel emotes
*/
async function fetchTwitchChannelEmotes(channelId: string): Promise<EmoteMap> {
const cacheKey = `emotes_twitch_channel_${channelId}`;
const cached = getCachedEmotes(cacheKey);
if (cached) return cached;
try {
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
const response = await fetch(
`/api/twitch-proxy?path=/helix/chat/emotes&broadcaster_id=${encodeURIComponent(channelId)}`,
);
const data = await response.json();
const emotes: EmoteMap = {};
if (data.data) {
data.data.forEach((emote: any) => {
const url = emote.images.url_1x.replace("/1.0", "/2.0");
emotes[emote.name] = url;
});
}
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.warn("Twitch channel emotes not loaded", error);
return {};
}
}
/**
* Fetch user's available emote sets (includes subscriptions)
*/
async function fetchUserEmoteSets(userId: string): Promise<EmoteMap> {
const cacheKey = `emotes_twitch_user_${userId}`;
const cached = getCachedEmotes(cacheKey);
if (cached) {
return cached;
}
try {
const emotes: EmoteMap = {};
let cursor: string | null = null;
let pageCount = 0;
// Fetch all pages
do {
// Route through server-side proxy — token comes from httpOnly cookie, not localStorage.
const proxyPath = cursor
? `/api/twitch-proxy?path=/helix/chat/emotes/user&user_id=${encodeURIComponent(userId)}&after=${encodeURIComponent(cursor)}`
: `/api/twitch-proxy?path=/helix/chat/emotes/user&user_id=${encodeURIComponent(userId)}`;
const apiResponse: Response = await fetch(proxyPath);
if (!apiResponse.ok) {
const errorText = await apiResponse.text();
console.error(
"Failed to fetch user emotes:",
apiResponse.status,
apiResponse.statusText,
errorText,
);
break;
}
const pageData: any = await apiResponse.json();
pageCount++;
if (pageData.data) {
pageData.data.forEach((emote: any) => {
const emoteId = emote.id;
// Prefer animated if available, otherwise static
const format = emote.format?.includes("animated")
? "animated"
: "static";
const url = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/${format}/dark/2.0`;
emotes[emote.name] = url;
});
}
// Get next cursor for pagination
cursor = pageData.pagination?.cursor || null;
} while (cursor);
setCachedEmotes(cacheKey, emotes);
return emotes;
} catch (error) {
console.error("Failed to fetch user emote sets:", error);
return {};
}
}
/**
* Initialize emotes for a given channel
*/
export async function initializeEmotes(
channelId: string,
channelName: string,
userId?: string,
): Promise<void> {
const promises = [
fetch7TVGlobalEmotes(),
fetch7TVEmotes(channelId),
fetchBTTVGlobalEmotes(),
fetchBTTVEmotes(channelId),
fetchFFZGlobalEmotes(),
fetchFFZEmotes(channelName),
fetchTwitchGlobalEmotes(),
fetchTwitchChannelEmotes(channelId),
];
if (userId) {
promises.push(fetchUserEmoteSets(userId));
}
const results = await Promise.all(promises);
emotes7tv = {
...results[0],
...results[1],
};
emotesBttv = {
...results[2],
...results[3],
};
emotesFfz = {
...results[4],
...results[5],
};
const globalEmotes = results[6];
const channelEmotes = results[7];
const userEmotes = userId ? results[8] : {};
emotesTwitch = {
...globalEmotes,
...channelEmotes,
...userEmotes,
};
// User emotes have been merged into emotesTwitch map
if (userId && Object.keys(userEmotes).length > 0) {
// Emotes successfully loaded and merged
}
// Dispatch a custom event to notify listeners that emotes have been reloaded
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new Event('emotes-reloaded'));
}
}
/**
* Create an optimized emote image HTML tag
*/
function replaceSize(url: string): string {
const parts = url.split("/");
// Replace the first number in the last part with '4'
// This works for: 1x.webp -> 4x.webp, 1.0 -> 4.0, 2 -> 4, etc.
if (url.includes('betterttv')) {
parts[parts.length - 1] = parts[parts.length - 1].replace(/\d+/, "3");
} else {
parts[parts.length - 1] = parts[parts.length - 1].replace(/\d+/, "4");
}
return parts.join("/");
}
function createEmoteImg(emoteUrl: string, emoteName: string): string {
// Convert any quality URL to 4x quality for tooltip (highest resolution)
let highQualityUrl = emoteUrl;
const splitedUrlSize = emoteUrl.split("/").pop();
if (
splitedUrlSize &&
(splitedUrlSize.includes("2") || splitedUrlSize.includes("1"))
) {
highQualityUrl = replaceSize(emoteUrl);
}
return `<img src="${emoteUrl}" alt="${escapeHtml(emoteName)}" title="${escapeHtml(emoteName)}" data-tooltip-url="${highQualityUrl}" data-emote-name="${escapeHtml(emoteName)}" class="chat-emote" style="height: 2em; vertical-align: middle; margin: 0 2px; cursor: pointer;" loading="lazy" />`;
}
/**
* Parse message text and replace emote codes with HTML image tags
* @param text - The message text
* @param twitchEmotes - Twitch emote data from message tags (emoteId -> positions array)
*/
export function parseMessageWithEmotes(
text: string,
twitchEmotes?: Record<string, string[]>,
): string {
let raw: string;
if (twitchEmotes && Object.keys(twitchEmotes).length > 0) {
const replacements: Array<{ start: number; end: number; html: string }> =
[];
Object.entries(twitchEmotes).forEach(([emoteId, positions]) => {
positions.forEach((pos) => {
const [start, end] = pos.split("-").map(Number);
const emoteName = text.substring(start, end + 1);
const emoteUrl = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/default/dark/2.0`;
replacements.push({
start,
end: end + 1,
html: createEmoteImg(emoteUrl, emoteName),
});
});
});
replacements.sort((a, b) => b.start - a.start);
let result = text;
replacements.forEach(({ start, end, html }) => {
result = result.substring(0, start) + html + result.substring(end);
});
raw = parseThirdPartyEmotes(result);
} else {
raw = parseThirdPartyEmotes(text);
}
// Final sanitization pass — strip anything outside the known-safe allowlist.
// This is the last line of defence against XSS from malformed emote names,
// crafted CDN responses, or future changes to the HTML-building helpers.
return DOMPurify.sanitize(raw, DOMPURIFY_CONFIG) as unknown as string;
}
/**
* Parse third-party emotes and @mentions in text
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Check if a word is a URL and convert to clickable link
*/
function linkifyUrl(word: string): string {
// Only match http(s) or www-prefixed URLs
const urlPattern = /^(https?:\/\/|www\.)[^\s]+$/i;
if (urlPattern.test(word)) {
let url = word;
if (word.toLowerCase().startsWith("www.")) {
url = "https://" + word;
}
// Explicitly block dangerous schemes even if the regex above is ever relaxed.
// URL objects normalise the protocol to lowercase, making detection reliable.
try {
const parsed = new URL(url);
const scheme = parsed.protocol; // e.g. "javascript:", "data:", "https:"
if (scheme !== "https:" && scheme !== "http:") {
return escapeHtml(word); // render as plain text
}
} catch {
return escapeHtml(word); // invalid URL — render as plain text
}
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="color: #6366f1; text-decoration: underline; cursor: pointer;">${escapeHtml(word)}</a>`;
}
return "";
}
/**
* Parse third-party emotes and @mentions in text
*/
function parseThirdPartyEmotes(text: string): string {
// Split by HTML tags to avoid breaking emote img tags
// We use a capture group so the delimiter (img tag) is included in the result array
const parts = text.split(/(<img[^>]*>)/);
const parsedParts = parts.map((part) => {
// Skip if it's already an HTML tag (our emote)
if (part.startsWith("<img")) {
return part;
}
// Split into words and parse
// Note: This approach loses exact whitespace formatting (collapses multiple spaces)
// A more robust approach would be to find matches and replace them, escaping the rest.
// For now, we'll stick to splitting by space but ensure we escape non-matches.
const words = part.split(" ");
const parsedWords = words.map((word) => {
// Check for URLs first
const linkified = linkifyUrl(word);
if (linkified) {
return linkified;
}
// Check for @mention
if (word.startsWith("@")) {
return `<strong>${escapeHtml(word)}</strong>`;
}
// Check Twitch native emotes (from cache map, manually typed)
if (emotesTwitch[word]) {
return createEmoteImg(emotesTwitch[word], word);
}
// Check 7TV
if (emotes7tv[word]) {
return createEmoteImg(emotes7tv[word], word);
}
// Check BTTV
if (emotesBttv[word]) {
return createEmoteImg(emotesBttv[word], word);
}
// Check FFZ
if (emotesFfz[word]) {
return createEmoteImg(emotesFfz[word], word);
}
// Return escaped word
return escapeHtml(word);
});
return parsedWords.join(" ");
});
return parsedParts.join("");
}
/**
* Search for emotes matching a query
*/
export function searchEmotes(
query: string,
limit: number = 10,
): Array<{ name: string; url: string; source: string }> {
if (!query) return [];
const results: Array<{ name: string; url: string; source: string }> = [];
const lowerQuery = query.toLowerCase();
// Debug: Show first 5 Twitch emote names
// Search Twitch emotes (includes global, channel, and user's subscriptions)
Object.entries(emotesTwitch).forEach(([name, url]) => {
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
results.push({ name, url, source: "Twitch" });
}
});
// Search 7TV emotes
Object.entries(emotes7tv).forEach(([name, url]) => {
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
results.push({ name, url, source: "7TV" });
}
});
// Search BTTV emotes
Object.entries(emotesBttv).forEach(([name, url]) => {
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
results.push({ name, url, source: "BTTV" });
}
});
// Search FFZ emotes
Object.entries(emotesFfz).forEach(([name, url]) => {
if (name.toLowerCase().includes(lowerQuery) && !isEmoteDisabled(name)) {
results.push({ name, url, source: "FFZ" });
}
});
// Sort by relevance (exact match first, then starts with, then contains)
results.sort((a, b) => {
const aLower = a.name.toLowerCase();
const bLower = b.name.toLowerCase();
if (aLower === lowerQuery) return -1;
if (bLower === lowerQuery) return 1;
if (aLower.startsWith(lowerQuery) && !bLower.startsWith(lowerQuery))
return -1;
if (bLower.startsWith(lowerQuery) && !aLower.startsWith(lowerQuery))
return 1;
return a.name.localeCompare(b.name);
});
const finalResults = results.slice(0, limit);
return finalResults;
}
/**
* Disabled emotes management (stored in localStorage as array of names)
*/
const DISABLED_EMOTES_KEY = 'disabled_emotes_v1';
export function getDisabledEmotes(): Set<string> {
try {
const raw = localStorage.getItem(DISABLED_EMOTES_KEY);
if (!raw) return new Set();
const arr = JSON.parse(raw) as string[];
return new Set(arr);
} catch {
return new Set();
}
}
export function isEmoteDisabled(name: string): boolean {
return getDisabledEmotes().has(name);
}
export function toggleDisableEmote(name: string): void {
try {
const set = getDisabledEmotes();
if (set.has(name)) set.delete(name);
else set.add(name);
localStorage.setItem(DISABLED_EMOTES_KEY, JSON.stringify(Array.from(set)));
// Notify listeners (reuse emotes-reloaded so UI refreshes emote lists and autocomplete)
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new Event('emotes-reloaded'));
}
} catch (e) {
console.warn('Failed to toggle emote disabled state', e);
}
}
/**
* Get all emotes organized by source
*/
export interface EmotesBySource {
twitch: Array<{ name: string; url: string }>;
"7tv": Array<{ name: string; url: string }>;
bttv: Array<{ name: string; url: string }>;
ffz: Array<{ name: string; url: string }>;
}
export function getAllEmotesOrganized(): EmotesBySource {
const organized = {
twitch: Object.entries(emotesTwitch)
.map(([name, url]) => ({ name, url }))
.sort((a, b) => a.name.localeCompare(b.name)),
"7tv": Object.entries(emotes7tv)
.map(([name, url]) => ({ name, url }))
.sort((a, b) => a.name.localeCompare(b.name)),
bttv: Object.entries(emotesBttv)
.map(([name, url]) => ({ name, url }))
.sort((a, b) => a.name.localeCompare(b.name)),
ffz: Object.entries(emotesFfz)
.map(([name, url]) => ({ name, url }))
.sort((a, b) => a.name.localeCompare(b.name)),
};
// Debug log to verify emotes are loaded
console.log("Emotes organized:", {
twitch: organized.twitch.length,
"7tv": organized["7tv"].length,
bttv: organized.bttv.length,
ffz: organized.ffz.length,
});
return organized;
}

129
src/lib/streamMonitor.ts Normal file
View File

@@ -0,0 +1,129 @@
import { getStreamStatuses } from "./twitch";
import type { PlatformSession } from "./types";
export interface StreamerToWatch {
id: string;
name: string;
displayName: string;
profileImageUrl?: string;
}
export interface LiveStreamer extends StreamerToWatch {
isLive: true;
title?: string;
viewerCount?: number;
}
/**
* Check which streamers from a list are currently live
* Uses batch API endpoint to check all streamers in a single request
*/
export async function checkLiveStreamers(
streamersToWatch: StreamerToWatch[],
accessToken: string,
clientId: string,
): Promise<LiveStreamer[]> {
if (!streamersToWatch.length) return [];
try {
const streamerIds = streamersToWatch.map((s) => s.id);
// Use batch endpoint to check all streamers in one API call
const statuses = await getStreamStatuses(
accessToken,
clientId,
streamerIds,
);
const liveStreamers: LiveStreamer[] = streamersToWatch
.filter((streamer) => statuses[streamer.id]?.isLive)
.map((streamer) => ({
...streamer,
isLive: true as const,
title: statuses[streamer.id]?.title,
viewerCount: statuses[streamer.id]?.viewerCount,
}));
return liveStreamers;
} catch (error) {
console.error("Error checking live streamers:", error);
throw error;
}
}
/**
* Get watched streamers from localStorage
*/
export function getWatchedStreamers(): StreamerToWatch[] {
const saved = localStorage.getItem("mixchat_watched_streamers");
if (!saved) return [];
try {
return JSON.parse(saved);
} catch (error) {
console.error("Failed to parse watched streamers:", error);
return [];
}
}
/**
* Save watched streamers to localStorage
*/
export function saveWatchedStreamers(streamers: StreamerToWatch[]): void {
localStorage.setItem("mixchat_watched_streamers", JSON.stringify(streamers));
}
/**
* Add a streamer to watch list
*/
export function addWatchedStreamer(streamer: StreamerToWatch): void {
const current = getWatchedStreamers();
if (!current.find((s) => s.id === streamer.id)) {
saveWatchedStreamers([...current, streamer]);
}
}
/**
* Remove a streamer from watch list
*/
export function removeWatchedStreamer(streamerId: string): void {
const current = getWatchedStreamers();
saveWatchedStreamers(current.filter((s) => s.id !== streamerId));
}
/**
* Check which streamers have already been notified for
*/
export function getNotifiedStreamers(): Set<string> {
const saved = localStorage.getItem("mixchat_notified_streamers");
if (!saved) return new Set();
try {
return new Set(JSON.parse(saved));
} catch (error) {
console.error("Failed to parse notified streamers:", error);
return new Set();
}
}
/**
* Mark a streamer as notified
*/
export function markStreamerNotified(streamerId: string): void {
const notified = getNotifiedStreamers();
notified.add(streamerId);
localStorage.setItem(
"mixchat_notified_streamers",
JSON.stringify(Array.from(notified)),
);
}
/**
* Mark a streamer as not notified (when they go offline)
*/
export function markStreamerOffline(streamerId: string): void {
const notified = getNotifiedStreamers();
notified.delete(streamerId);
localStorage.setItem(
"mixchat_notified_streamers",
JSON.stringify(Array.from(notified)),
);
}

629
src/lib/twitch.ts Normal file
View File

@@ -0,0 +1,629 @@
import type { PlatformSession, ChatMessage } from "./types.js";
import { getBadge, getBadgeDetail } from "./badges.js";
const TWITCH_API_BASE = "https://api.twitch.tv/helix";
const TWITCH_AUTH_BASE = "https://id.twitch.tv/oauth2";
const BATCH_SIZE = 100;
export const getTwitchAccessToken = async (
code: string,
clientId: string,
clientSecret: string,
redirectUri: string,
) => {
try {
const response = await fetch(`${TWITCH_AUTH_BASE}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
} catch (error) {
console.error("Twitch token exchange failed:", error);
throw error;
}
};
export const refreshTwitchAccessToken = async (
refreshToken: string,
clientId: string,
clientSecret: string,
) => {
try {
const response = await fetch(`${TWITCH_AUTH_BASE}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!response.ok) {
throw new Error(`Failed to refresh token: ${response.status}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresIn: data.expires_in,
};
} catch (error) {
console.error("Twitch token refresh failed:", error);
throw error;
}
};
export const getTwitchUser = async (accessToken: string, clientId: string) => {
try {
const response = await fetch(`${TWITCH_API_BASE}/users`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
});
if (!response.ok) {
throw new Error(
`Twitch API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
if (!data.data || !data.data[0]) {
throw new Error("No user data returned from Twitch API");
}
const user = data.data[0];
return {
userId: user.id,
displayName: user.display_name,
login: user.login,
profileImageUrl: user.profile_image_url,
};
} catch (error) {
console.error("Failed to get Twitch user:", error);
throw error;
}
};
export const getTwitchUserById = async (
accessToken: string,
clientId: string,
userId: string,
) => {
try {
const response = await fetch(`${TWITCH_API_BASE}/users?id=${userId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
});
if (!response.ok) {
throw new Error(
`Twitch API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
if (!data.data || !data.data[0]) {
return null;
}
const user = data.data[0];
return {
userId: user.id,
displayName: user.display_name,
login: user.login,
profileImageUrl: user.profile_image_url,
};
} catch (error) {
console.error("Failed to get Twitch user by ID:", error);
return null;
}
};
export const getFollowedChannels = async (
accessToken: string,
clientId: string,
userId: string,
) => {
try {
let followedChannels: any[] = [];
let cursor: string | undefined = undefined;
while (true) {
const url = new URL(`${TWITCH_API_BASE}/channels/followed`);
url.searchParams.set("user_id", userId);
url.searchParams.set("first", "100");
if (cursor) {
url.searchParams.set("after", cursor);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
});
if (!response.ok) {
if (response.status === 401) {
throw new Error(
`Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`,
);
}
throw new Error(`Twitch API error: ${response.status}`);
}
const data = await response.json();
const pageChannels = (data.data || []).map((channel: any) => ({
id: channel.broadcaster_id,
name: channel.broadcaster_login,
displayName:
channel.broadcaster_name ||
channel.display_name ||
channel.broadcaster_login,
title: channel.game_name || "",
thumbnail: channel.thumbnail_url,
}));
followedChannels = followedChannels.concat(pageChannels);
if (data.pagination?.cursor) {
cursor = data.pagination.cursor;
} else {
break;
}
}
const batchSize = BATCH_SIZE;
for (let i = 0; i < followedChannels.length; i += batchSize) {
const batch = followedChannels.slice(i, i + batchSize);
const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&");
const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
});
if (usersResponse.ok) {
const usersData = await usersResponse.json();
const userProfiles = new Map(
(usersData.data || []).map((user: any) => [
user.id,
user.profile_image_url,
]),
);
batch.forEach((ch: any) => {
ch.profileImageUrl = userProfiles.get(ch.id);
});
}
}
const streamDataMap = new Map<string, any>();
for (let i = 0; i < followedChannels.length; i += batchSize) {
const batch = followedChannels.slice(i, i + batchSize);
const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&");
const streamsResponse = await fetch(
`${TWITCH_API_BASE}/streams?${userIdParams}&first=100`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
},
);
if (streamsResponse.ok) {
const streamsData = await streamsResponse.json();
(streamsData.data || []).forEach((s: any) => {
streamDataMap.set(s.user_id, s);
});
}
}
followedChannels.forEach((ch: any) => {
if (streamDataMap.has(ch.id)) {
ch.viewerCount = streamDataMap.get(ch.id).viewer_count;
} else {
ch.viewerCount = 0;
}
});
// Only return online channels for the sidebar
const onlineChannels = followedChannels.filter((ch: any) =>
streamDataMap.has(ch.id),
);
const sorted = onlineChannels.sort((a, b) => {
const nameA = (a?.displayName || a?.name || "").toLowerCase();
const nameB = (b?.displayName || b?.name || "").toLowerCase();
return nameA.localeCompare(nameB);
});
return sorted;
} catch (error) {
console.error("Failed to get followed channels:", error);
throw error;
}
};
export const getAllFollowedChannels = async (
accessToken: string,
clientId: string,
userId: string,
) => {
try {
let followedChannels: any[] = [];
let cursor: string | undefined = undefined;
while (true) {
const url = new URL(`${TWITCH_API_BASE}/channels/followed`);
url.searchParams.set("user_id", userId);
url.searchParams.set("first", "100");
if (cursor) {
url.searchParams.set("after", cursor);
}
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
});
if (!response.ok) {
if (response.status === 401) {
throw new Error(
`Twitch API error: 401 - Unauthorized. Token may be expired or invalid.`,
);
}
throw new Error(`Twitch API error: ${response.status}`);
}
const data = await response.json();
const pageChannels = (data.data || []).map((channel: any) => ({
id: channel.broadcaster_id,
name: channel.broadcaster_login,
displayName:
channel.broadcaster_name ||
channel.display_name ||
channel.broadcaster_login,
title: channel.game_name || "",
thumbnail: channel.thumbnail_url,
}));
followedChannels = followedChannels.concat(pageChannels);
if (data.pagination?.cursor) {
cursor = data.pagination.cursor;
} else {
break;
}
}
const batchSize = BATCH_SIZE;
for (let i = 0; i < followedChannels.length; i += batchSize) {
const batch = followedChannels.slice(i, i + batchSize);
const userIds = batch.map((ch: any) => `id=${ch.id}`).join("&");
const usersResponse = await fetch(`${TWITCH_API_BASE}/users?${userIds}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
});
if (usersResponse.ok) {
const usersData = await usersResponse.json();
const userProfiles = new Map(
(usersData.data || []).map((user: any) => [
user.id,
user.profile_image_url,
]),
);
batch.forEach((ch: any) => {
ch.profileImageUrl = userProfiles.get(ch.id);
});
}
}
// Fetch stream data to mark which channels are online
const streamDataMap = new Map<string, any>();
for (let i = 0; i < followedChannels.length; i += batchSize) {
const batch = followedChannels.slice(i, i + batchSize);
const userIdParams = batch.map((ch: any) => `user_id=${ch.id}`).join("&");
const streamsResponse = await fetch(
`${TWITCH_API_BASE}/streams?${userIdParams}&first=100`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
},
);
if (streamsResponse.ok) {
const streamsData = await streamsResponse.json();
(streamsData.data || []).forEach((s: any) => {
streamDataMap.set(s.user_id, s);
});
}
}
followedChannels.forEach((ch: any) => {
if (streamDataMap.has(ch.id)) {
ch.viewerCount = streamDataMap.get(ch.id).viewer_count;
} else {
ch.viewerCount = 0;
}
});
// Return all followed channels, sorted with online first
const sorted = followedChannels.sort((a, b) => {
const aIsOnline = streamDataMap.has(a.id) ? 1 : 0;
const bIsOnline = streamDataMap.has(b.id) ? 1 : 0;
if (aIsOnline !== bIsOnline) {
return bIsOnline - aIsOnline; // Online channels first
}
const nameA = (a?.displayName || a?.name || "").toLowerCase();
const nameB = (b?.displayName || b?.name || "").toLowerCase();
return nameA.localeCompare(nameB);
});
return sorted;
} catch (error) {
console.error("Failed to get all followed channels:", error);
throw error;
}
};
export const getStreamStatus = async (
accessToken: string,
clientId: string,
userId: string,
): Promise<{
isLive: boolean;
title?: string;
viewerCount?: number;
startedAt?: Date;
} | null> => {
try {
const response = await fetch(
`${TWITCH_API_BASE}/streams?user_id=${userId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
},
);
if (!response.ok) {
throw new Error(`Twitch API error: ${response.status}`);
}
const data = await response.json();
const stream = data.data?.[0];
if (!stream) {
return { isLive: false };
}
return {
isLive: true,
title: stream.title,
viewerCount: stream.viewer_count,
startedAt: new Date(stream.started_at),
};
} catch (error) {
console.error("Failed to get stream status:", error);
return null;
}
};
/**
* Batch query stream status for multiple users (optimized for rate limiting)
* Instead of making N individual requests, makes 1 request with multiple user_ids
* Reduces API calls by ~90% when checking multiple streamers
*/
export const getStreamStatuses = async (
accessToken: string,
clientId: string,
userIds: string[],
): Promise<
Record<
string,
{ isLive: boolean; title?: string; viewerCount?: number; startedAt?: Date }
>
> => {
try {
if (!userIds.length) {
return {};
}
// Build query with all user IDs
const params = userIds.map((id) => `user_id=${id}`).join("&");
const response = await fetch(
`${TWITCH_API_BASE}/streams?${params}&first=100`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Client-ID": clientId,
},
},
);
if (!response.ok) {
throw new Error(`Twitch API error: ${response.status}`);
}
const data = await response.json();
const result: Record<
string,
{
isLive: boolean;
title?: string;
viewerCount?: number;
startedAt?: Date;
}
> = {};
// Initialize all user IDs as offline
userIds.forEach((id) => {
result[id] = { isLive: false };
});
// Update with actual live streams
data.data?.forEach((stream: any) => {
result[stream.user_id] = {
isLive: true,
title: stream.title,
viewerCount: stream.viewer_count,
startedAt: new Date(stream.started_at),
};
});
console.log(
`Batch stream check: ${userIds.length} streamers checked in 1 request`,
);
return result;
} catch (error) {
console.error("Failed to get batch stream status:", error);
throw error;
}
};
export const getRecentMessages = async (
channelName: string,
): Promise<ChatMessage[]> => {
try {
const response = await fetch(
`https://recent-messages.robotty.de/api/v2/recent-messages/${channelName}`,
);
if (!response.ok) {
console.warn(
`Failed to fetch recent messages for ${channelName}:`,
response.status,
);
return [];
}
const data = await response.json();
const messages: ChatMessage[] = (data.messages || [])
.map((rawMsg: string) => {
try {
const tagsMatch = rawMsg.match(/^@(.+?)\s+:/);
const userMatch = rawMsg.match(/\s:([^!]+)!/);
const contentMatch = rawMsg.match(/PRIVMSG #[^\s]+ :(.+)$/);
if (!userMatch || !contentMatch) {
console.warn("Failed to parse message:", rawMsg.substring(0, 100));
return null;
}
const author = userMatch[1];
const content = contentMatch[1];
// Parse tags
let tags: Record<string, string> = {};
if (tagsMatch && tagsMatch[1]) {
const tagPairs = tagsMatch[1].split(";");
tags = Object.fromEntries(
tagPairs.map((pair) => {
const [key, value] = pair.split("=");
return [key, value || ""];
}),
);
}
let badges: Record<string, string> = {};
let badgeDetails: Record<string, { url: string; title: string }> = {};
if (tags["badges"]) {
tags["badges"].split(",").forEach((badge: string) => {
const [name, version] = badge.split("/");
const detail = getBadgeDetail(name, version);
if (detail) {
badges[name] = detail.url;
badgeDetails[name] = { url: detail.url, title: detail.title };
}
});
}
let emotes: Record<string, string[]> = {};
if (tags["emotes"] && tags["emotes"] !== "") {
emotes = Object.fromEntries(
tags["emotes"].split("/").map((emote: string) => {
const [id, positions] = emote.split(":");
return [id, positions.split(",")];
}),
);
}
// Use the actual Twitch message ID if available, otherwise fall back to a composite ID
const messageId = tags["id"] || `${tags["user-id"] || "unknown"}_${tags["tmi-sent-ts"] || Date.now()}`;
const message: ChatMessage = {
id: messageId,
platform: "twitch",
author,
content,
timestamp: new Date(parseInt(tags["tmi-sent-ts"]) || Date.now()),
userId: tags["user-id"] || "",
authorColor: tags["color"] || "#FFFFFF",
badges,
badgeDetails,
emotes,
isBot: tags["bot"] === "1",
mentionsUser: false,
isPreloaded: true, // Mark as preloaded historical message
};
return message;
} catch (error) {
console.error(
"Error parsing message:",
rawMsg.substring(0, 100),
error,
);
return null;
}
})
.filter((msg: ChatMessage | null): msg is ChatMessage => msg !== null);
return messages;
} catch (error) {
console.error("Failed to fetch recent messages:", error);
return [];
}
};

51
src/lib/types.ts Normal file
View File

@@ -0,0 +1,51 @@
export const PLATFORMS = ["twitch", "youtube"] as const;
export type Platform = (typeof PLATFORMS)[number];
export const MESSAGE_TYPES = [
"chat",
"cheer",
"subscription",
"resub",
"subgift",
"redemption",
] as const;
export type MessageType = (typeof MESSAGE_TYPES)[number];
export interface ChatMessage {
id: string;
platform: Platform;
author: string;
content: string;
timestamp: Date;
authorAvatar?: string;
authorColor?: string;
userId?: string;
mentionsUser?: boolean;
emotes?: Record<string, string[]>; // Twitch emote data: emoteId -> positions
replyTo?: {
id: string;
author: string;
content: string;
};
isBot?: boolean;
badges?: Record<string, string>; // badgeName -> imageUrl
badgeDetails?: Record<string, { url: string; title: string }>; // detailed badge info
isCurrentUser?: boolean; // True if message is from the logged-in user
optimistic?: boolean; // True if this message is a local optimistic placeholder
isPreloaded?: boolean; // True if message is from historical data (not live chat)
messageType?: MessageType; // Type of message (chat, cheer, subscription, etc.)
rewardId?: string; // ID of the redeemed reward
}
export interface PlatformSession {
platform: Platform;
accessToken: string;
refreshToken?: string;
expiresAt?: number;
userId: string;
displayName: string;
username?: string;
profileImageUrl?: string;
badges?: Record<string, string>; // badgeName -> imageUrl
badgeDetails?: Record<string, { url: string; title: string }>; // detailed badge info
}

6
src/lib/version.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* App version constant
* Update here to reflect current release version
*/
export const APP_VERSION = "0.5.0-beta";
export const DISPLAY_VERSION = "Beta 0.5";

114
src/lib/youtube.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* YouTube OAuth and API Client
*/
export interface YouTubeTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}
export interface YouTubeUser {
userId: string;
displayName: string;
profileImageUrl: string;
}
/**
* Exchange authorization code for access token
*/
export async function getYoutubeAccessToken(
code: string,
clientId: string,
clientSecret: string,
redirectUri: string,
): Promise<YouTubeTokenResponse> {
const params = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: "authorization_code",
});
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!response.ok) {
throw new Error(
`Failed to get YouTube access token: ${response.statusText}`,
);
}
return response.json();
}
/**
* Get YouTube user information
*/
export async function getYoutubeUser(
accessToken: string,
): Promise<YouTubeUser> {
const response = await fetch(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!response.ok) {
throw new Error(`Failed to get YouTube user: ${response.statusText}`);
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
throw new Error("No YouTube channel found");
}
const channel = data.items[0];
return {
userId: channel.id,
displayName: channel.snippet.title,
profileImageUrl: channel.snippet.thumbnails?.default?.url || "",
};
}
/**
* Refresh YouTube access token
*/
export async function refreshYoutubeToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): Promise<YouTubeTokenResponse> {
const params = new URLSearchParams({
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
grant_type: "refresh_token",
});
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (!response.ok) {
throw new Error(`Failed to refresh YouTube token: ${response.statusText}`);
}
return response.json();
}

50
src/lib/youtubeLinks.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* YouTube channel linking helpers.
* Stores a mapping of Twitch channel ID -> YouTube channel URL in localStorage.
*/
const STORAGE_KEY = "mixchat_youtube_links";
export interface YouTubeLink {
twitchChannelId: string;
twitchDisplayName: string;
youtubeUrl: string;
}
export function getYoutubeLinks(): YouTubeLink[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
export function addYoutubeLink(link: YouTubeLink): void {
const links = getYoutubeLinks();
// Replace if already exists for this twitch channel
const idx = links.findIndex(
(l) => l.twitchChannelId === link.twitchChannelId,
);
if (idx >= 0) {
links[idx] = link;
} else {
links.push(link);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
}
export function removeYoutubeLink(twitchChannelId: string): void {
const links = getYoutubeLinks().filter(
(l) => l.twitchChannelId !== twitchChannelId,
);
localStorage.setItem(STORAGE_KEY, JSON.stringify(links));
}
export function getYoutubeLinkForChannel(
twitchChannelId: string,
): string | null {
const links = getYoutubeLinks();
const found = links.find((l) => l.twitchChannelId === twitchChannelId);
return found?.youtubeUrl ?? null;
}

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ request }) => {
const hostHeader = request.headers.get("host") || "";
const host = hostHeader.split(":")[0];
const isIpAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(host);
const parentDomain = isIpAddress ? "mixchat.local" : host;
return new Response(JSON.stringify({ parentDomain }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -0,0 +1,190 @@
import type { APIRoute } from "astro";
import { refreshTwitchAccessToken } from "../../lib/twitch";
import { refreshYoutubeToken } from "../../lib/youtube";
export const POST: APIRoute = async ({ request, cookies }) => {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
try {
const isDev = import.meta.env.MODE === 'development';
const isSecure = !isDev;
const body = await request.json().catch(() => ({}));
const { platform } = body as { platform?: string };
if (!platform) {
return new Response(JSON.stringify({ error: "Platform required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
let accessToken: string | undefined;
let refreshTokenOut: string | undefined;
let expiresIn: number | undefined;
if (platform === "twitch") {
// Read the refresh token from the secure httpOnly cookie.
const refreshToken = cookies.get("twitch_refresh_token")?.value;
if (!refreshToken) {
return new Response(
JSON.stringify({ error: "No refresh token available" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
const clientSecret = import.meta.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.error("Missing Twitch credentials in environment");
return new Response(
JSON.stringify({ error: "Server configuration error" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
const tokenData = await refreshTwitchAccessToken(
refreshToken,
clientId,
clientSecret,
);
accessToken = tokenData.accessToken;
refreshTokenOut = tokenData.refreshToken;
expiresIn = tokenData.expiresIn;
if (accessToken) {
cookies.set("twitch_token", accessToken, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: expiresIn || 3600,
path: "/",
});
}
if (refreshTokenOut) {
cookies.set("twitch_refresh_token", refreshTokenOut, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: 365 * 24 * 60 * 60,
path: "/",
});
}
} else if (platform === "youtube") {
// Read the refresh token from the secure httpOnly cookie.
const refreshToken = cookies.get("youtube_refresh_token")?.value;
if (!refreshToken) {
// 401 is expected when user hasn't logged in with YouTube yet
return new Response(
JSON.stringify({ error: "No refresh token available" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const clientId = import.meta.env.PUBLIC_YOUTUBE_CLIENT_ID;
const clientSecret = import.meta.env.YOUTUBE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.error("Missing YouTube credentials in environment");
return new Response(
JSON.stringify({ error: "Server configuration error" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
const tokenData = await refreshYoutubeToken(
refreshToken,
clientId,
clientSecret,
);
accessToken = tokenData.access_token;
refreshTokenOut = tokenData.refresh_token;
expiresIn = tokenData.expires_in;
if (accessToken) {
cookies.set("youtube_token", accessToken, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: expiresIn || 3600,
path: "/",
});
}
if (refreshTokenOut) {
cookies.set("youtube_refresh_token", refreshTokenOut, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: 365 * 24 * 60 * 60,
path: "/",
});
}
} else {
return new Response(JSON.stringify({ error: "Unsupported platform" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
console.log("Token refreshed successfully", {
platform,
expiresIn,
timestamp: new Date().toISOString(),
});
return new Response(
JSON.stringify({
accessToken,
refreshToken: refreshTokenOut,
expiresIn,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
let statusCode = 500;
if (
errorMessage.includes("401") ||
errorMessage.includes("Invalid refresh token")
) {
statusCode = 401;
} else if (errorMessage.includes("400")) {
statusCode = 400;
}
// Log full details server-side; return only a generic message to the client.
console.error("Token refresh failed:", {
error: errorMessage,
timestamp: new Date().toISOString(),
status: statusCode,
});
return new Response(
JSON.stringify({
error:
statusCode === 401
? "Session expired. Please log in again."
: "Token refresh failed. Please try again.",
}),
{
status: statusCode,
headers: { "Content-Type": "application/json" },
},
);
}
};

View File

@@ -0,0 +1,142 @@
import type { APIRoute } from "astro";
const GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
export const GET: APIRoute = async ({ url }) => {
try {
const channel = url.searchParams.get("channel");
if (!channel) {
return new Response(
JSON.stringify({ error: "Channel parameter required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Grab a playback access token from Twitch on the server side.
// The token is tied to the server's IP, so everything that follows
// must also go through the server to avoid 403 errors from the CDN.
const gqlRes = await fetch("https://gql.twitch.tv/gql", {
method: "POST",
headers: {
"Client-ID": GQL_CLIENT_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: "PlaybackAccessToken",
extensions: {
persistedQuery: {
version: 1,
sha256Hash:
"0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
},
},
variables: {
isLive: true,
login: channel,
isVod: false,
vodID: "",
playerType: "embed",
},
}),
});
if (!gqlRes.ok) {
throw new Error(`Twitch GQL error: ${gqlRes.status}`);
}
const gqlData = await gqlRes.json();
const tokenData = gqlData?.data?.streamPlaybackAccessToken;
if (!tokenData?.value || !tokenData?.signature) {
return new Response(
JSON.stringify({ error: "Stream not available or channel is offline" }),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
// Fetch the master playlist from Twitch's CDN using the token we just got.
const usherParams = new URLSearchParams({
client_id: GQL_CLIENT_ID,
token: tokenData.value,
sig: tokenData.signature,
allow_source: "true",
allow_audio_only: "true",
allow_spectre: "false",
fast_bread: "true",
p: String(Math.floor(Math.random() * 999999)),
});
const usherUrl = `https://usher.ttvnw.net/api/channel/hls/${encodeURIComponent(channel)}.m3u8?${usherParams}`;
const masterRes = await fetch(usherUrl);
if (!masterRes.ok) {
if (masterRes.status === 404) {
return new Response(
JSON.stringify({ error: "Channel is offline or does not exist" }),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
throw new Error(`Usher fetch failed: ${masterRes.status}`);
}
const masterText = await masterRes.text();
// The master playlist lists qualities from best to worst, so the first URL is source quality.
const masterLines = masterText.split("\n");
let bestVariantUrl = "";
for (const line of masterLines) {
const trimmed = line.trim();
if (trimmed.startsWith("http")) {
bestVariantUrl = trimmed;
break;
}
}
if (!bestVariantUrl) {
return new Response(
JSON.stringify({ error: "Could not parse master playlist" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
// Fetch the quality-specific sub-playlist. This one contains the actual .ts segment URLs.
const variantRes = await fetch(bestVariantUrl);
if (!variantRes.ok) {
throw new Error(`Variant playlist fetch failed: ${variantRes.status}`);
}
const variantText = await variantRes.text();
// Rewrite every segment URL to go through our proxy so the browser
// doesn't run into CORS issues when HLS.js fetches the video chunks.
const rewrittenVariant = variantText
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith("http")) {
return `/api/stream-segment?url=${encodeURIComponent(trimmed)}`;
}
return line;
})
.join("\n");
return new Response(rewrittenVariant, {
status: 200,
headers: {
"Content-Type": "application/vnd.apple.mpegurl",
"Access-Control-Allow-Origin": "*",
// Tell HLS.js to always re-fetch — this is a live stream
"Cache-Control": "no-cache, no-store",
},
});
} catch (error) {
console.error("Stream playlist proxy error:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "Failed to get playlist",
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
};

View File

@@ -0,0 +1,106 @@
import type { APIRoute } from "astro";
/**
* Hostnames that this proxy is permitted to fetch on behalf of the client.
* Twitch stream segments are served exclusively from these CDN domains.
* Any URL pointing elsewhere is rejected to prevent SSRF attacks.
*/
const ALLOWED_HOSTS = new Set([
"video.twitch.tv",
"usher.twitchsvc.net",
"usher.twitch.tv",
"usher.ttvnw.net",
"d1m7jfoe9zdc1j.cloudfront.net", // Twitch CloudFront CDN
"vod.twitch.tv",
// Twitch HLS CDN — segment URLs are served from dynamic subdomains of ttvnw.net
// e.g. use14.playlist.ttvnw.net, vod.us-east-2.aws.ttvnw.net
]);
// Allow any *.ttvnw.net subdomain (Twitch's primary HLS CDN)
function isAllowedHost(hostname: string): boolean {
return ALLOWED_HOSTS.has(hostname) || hostname.endsWith(".ttvnw.net");
}
export const GET: APIRoute = async ({ url }) => {
try {
const remote = url.searchParams.get("url");
if (!remote) {
return new Response(JSON.stringify({ error: "url parameter required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
let parsed: URL;
try {
parsed = new URL(decodeURIComponent(remote));
} catch {
return new Response(
JSON.stringify({ error: "Invalid URL" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Enforce HTTPS and restrict to known Twitch CDN hostnames.
if (parsed.protocol !== "https:") {
return new Response(
JSON.stringify({ error: "Only HTTPS URLs are allowed" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (!isAllowedHost(parsed.hostname)) {
return new Response(
JSON.stringify({ error: `Disallowed host: ${parsed.hostname}` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Fetch the remote segment/playlist from server-side (avoid browser CORS)
const res = await fetch(parsed.href, {
headers: {
"User-Agent": "Mixchat/1.0",
Accept: "*/*",
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error(
"Failed fetching remote segment",
res.status,
parsed.href,
text.slice(0, 200),
);
return new Response(
JSON.stringify({ error: `Remote fetch failed: ${res.status}` }),
{ status: 502, headers: { "Content-Type": "application/json" } },
);
}
// Clone response body and forward content-type
const contentType =
res.headers.get("content-type") || "application/octet-stream";
const body = await res.arrayBuffer();
return new Response(body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": "*",
// Allow range requests if remote supports it
"Accept-Ranges": res.headers.get("accept-ranges") || "bytes",
},
});
} catch (error) {
console.error("Stream segment proxy error:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "Failed to proxy segment",
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
};

View File

@@ -0,0 +1,81 @@
import type { APIRoute } from "astro";
const GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
export const GET: APIRoute = async ({ url }) => {
try {
const channel = url.searchParams.get("channel");
if (!channel) {
return new Response(
JSON.stringify({ error: "Channel parameter required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Fetch only the playback access token from Twitch GQL (server-side).
// We do NOT fetch the CDN playlist URL here — that must be done by the
// browser so that the CDN token is bound to the client's IP, not ours.
const gqlRes = await fetch("https://gql.twitch.tv/gql", {
method: "POST",
headers: {
"Client-ID": GQL_CLIENT_ID,
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: "PlaybackAccessToken",
extensions: {
persistedQuery: {
version: 1,
sha256Hash:
"0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712",
},
},
variables: {
isLive: true,
login: channel,
isVod: false,
vodID: "",
playerType: "embed",
},
}),
});
if (!gqlRes.ok) {
throw new Error(`Twitch GQL error: ${gqlRes.status}`);
}
const gqlData = await gqlRes.json();
const tokenData = gqlData?.data?.streamPlaybackAccessToken;
if (!tokenData?.value || !tokenData?.signature) {
return new Response(
JSON.stringify({ error: "Stream not available or channel is offline" }),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
}
// Return the token & sig to the client.
// The browser will construct the usher.ttvnw.net URL itself so all CDN
// segment requests originate from the correct client IP (fixing 403s).
return new Response(
JSON.stringify({
success: true,
token: tokenData.value,
sig: tokenData.signature,
clientId: GQL_CLIENT_ID,
channel,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Stream URL error:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "Failed to get stream URL",
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
};

View File

@@ -0,0 +1,97 @@
import type { APIRoute } from "astro";
/**
* Allowlist of Twitch Helix API path prefixes this proxy is permitted to call.
* Any path not matching one of these is rejected with 400.
* This prevents the endpoint being abused to reach arbitrary external services.
*/
const ALLOWED_PATH_PREFIXES = [
"/helix/chat/emotes/global",
"/helix/chat/emotes/user",
"/helix/chat/emotes", // covers /helix/chat/emotes?broadcaster_id=...
"/helix/chat/badges/global",
"/helix/chat/badges", // covers /helix/chat/badges?broadcaster_id=...
] as const;
const TWITCH_API_BASE = "https://api.twitch.tv";
/**
* Server-side proxy for Twitch Helix API calls.
*
* Purpose: The client no longer needs to store the Twitch access token in
* localStorage to make Twitch API calls (emotes, badges). Instead it calls
* this endpoint, which reads the token from the httpOnly "twitch_token" cookie
* and forwards the request to Twitch on the server side.
*
* Usage: GET /api/twitch-proxy?path=/helix/chat/emotes/global
* GET /api/twitch-proxy?path=/helix/chat/emotes&broadcaster_id=12345
* GET /api/twitch-proxy?path=/helix/chat/badges/global
*/
export const GET: APIRoute = async ({ url, cookies }) => {
try {
const path = url.searchParams.get("path");
if (!path) {
return new Response(
JSON.stringify({ error: "path parameter required" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Validate the path against the allowlist
const isAllowed = ALLOWED_PATH_PREFIXES.some((prefix) =>
path === prefix || path.startsWith(prefix + "?") || path.startsWith(prefix + "/"),
);
if (!isAllowed) {
return new Response(
JSON.stringify({ error: `Path not permitted: ${path}` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Read the access token from the httpOnly cookie — never from client input.
const accessToken = cookies.get("twitch_token")?.value;
if (!accessToken) {
return new Response(
JSON.stringify({ error: "Not authenticated" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
if (!clientId) {
return new Response(
JSON.stringify({ error: "Server configuration error" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
// Build the full Twitch API URL, forwarding any extra query params from the
// original request (e.g. broadcaster_id, user_id, after cursor).
const forwardParams = new URLSearchParams(url.searchParams);
forwardParams.delete("path"); // "path" is our own param, not Twitch's
const targetUrl = `${TWITCH_API_BASE}${path}${forwardParams.size > 0 ? `?${forwardParams}` : ""
}`;
const response = await fetch(targetUrl, {
headers: {
"Client-Id": clientId,
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const data = await response.json();
return new Response(JSON.stringify(data), {
status: response.status,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Twitch proxy error:", error);
return new Response(
JSON.stringify({ error: "Proxy request failed" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
};

View File

@@ -0,0 +1,35 @@
import type { APIRoute } from "astro";
import { getTwitchUserById } from "../../lib/twitch";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
const authHeader = request.headers.get("Authorization");
if (!userId) {
return new Response("User ID is required", { status: 400 });
}
if (!authHeader) {
return new Response("Authorization header is required", { status: 401 });
}
const accessToken = authHeader.replace("Bearer ", "");
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
try {
const user = await getTwitchUserById(accessToken, clientId, userId);
if (!user) {
return new Response("User not found", { status: 404 });
}
return new Response(JSON.stringify(user), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Failed to fetch user info:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -0,0 +1,154 @@
import type { APIRoute } from "astro";
/**
* POST /api/youtube-chat
*
* Sends a message to a YouTube live chat.
*
* Body (JSON):
* videoId YouTube video ID of the live stream
* message Text message to send
* accessToken YouTube OAuth2 access token (user must have granted live-chat scope)
*/
export const POST: APIRoute = async ({ request }) => {
const headers = { "Content-Type": "application/json" };
try {
const body = await request.json();
const { videoId, message, accessToken } = body as {
videoId?: string;
message?: string;
accessToken?: string;
};
if (!videoId || !message || !accessToken) {
return new Response(
JSON.stringify({
error: "videoId, message, and accessToken are required",
}),
{ status: 400, headers },
);
}
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
// 1. Get the liveChatId from the video
const liveChatResult = await getLiveChatId(videoId, accessToken, apiKey);
if (!liveChatResult.liveChatId) {
console.error("getLiveChatId failed:", liveChatResult.debugInfo);
return new Response(
JSON.stringify({
error:
liveChatResult.debugInfo ||
"Could not find live chat for this video. The stream may not be live.",
}),
{ status: 422, headers },
);
}
const liveChatId = liveChatResult.liveChatId;
// 2. Send the message
const sendRes = await fetch(
"https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
snippet: {
liveChatId,
type: "textMessageEvent",
textMessageDetails: {
messageText: message,
},
},
}),
},
);
if (!sendRes.ok) {
const errData = await sendRes.json().catch(() => ({}));
const errMsg =
errData?.error?.message || `YouTube API error: ${sendRes.status}`;
console.error("YouTube send message error:", errData);
return new Response(JSON.stringify({ error: errMsg }), {
status: sendRes.status,
headers,
});
}
const result = await sendRes.json();
return new Response(JSON.stringify({ success: true, id: result.id }), {
status: 200,
headers,
});
} catch (error) {
console.error("youtube-chat API error:", error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal server error",
}),
{ status: 500, headers },
);
}
};
/**
* Resolve the liveChatId for a given YouTube video.
* Uses the API key for video lookup (public data), falls back to access token.
*/
async function getLiveChatId(
videoId: string,
accessToken: string,
apiKey?: string,
): Promise<{ liveChatId: string | null; debugInfo: string }> {
// Try with API key first (more reliable for public video data), then fall back to OAuth token
const urlBase = `https://www.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=${encodeURIComponent(videoId)}`;
let res: Response;
if (apiKey) {
res = await fetch(`${urlBase}&key=${apiKey}`);
} else {
res = await fetch(urlBase, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return {
liveChatId: null,
debugInfo: `YouTube videos.list returned ${res.status}: ${errBody.slice(0, 200)}`,
};
}
const data = await res.json();
if (!data.items || data.items.length === 0) {
return {
liveChatId: null,
debugInfo: `No video found for ID "${videoId}". The video may not exist or may be private.`,
};
}
const liveDetails = data.items[0]?.liveStreamingDetails;
if (!liveDetails) {
return {
liveChatId: null,
debugInfo: `Video "${videoId}" has no liveStreamingDetails. It may not be a live stream.`,
};
}
const chatId = liveDetails.activeLiveChatId;
if (!chatId) {
return {
liveChatId: null,
debugInfo: `Video "${videoId}" has liveStreamingDetails but no activeLiveChatId. The stream may have ended.`,
};
}
return { liveChatId: chatId, debugInfo: "" };
}

View File

@@ -0,0 +1,206 @@
import type { APIRoute } from 'astro';
/**
* GET /api/youtube-emotes?videoId=xxx
*
* Fetches the channel's custom emojis (membership emotes) by scraping the
* same YouTube page that the youtube-chat library uses internally.
*
* YouTube's official Data API v3 does NOT expose channel custom emojis, so
* we extract them from ytInitialData embedded in the live-chat frame HTML,
* which is the same source the Innertube API returns to the library.
*/
interface YTEmote {
name: string; // shortcut / emojiText, e.g. ":channelName_emote1:"
url: string; // CDN image URL
isCustomEmoji: boolean;
}
export const GET: APIRoute = async ({ url }) => {
const videoId = url.searchParams.get('videoId');
if (!videoId) {
return new Response(
JSON.stringify({ error: 'videoId query parameter is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
try {
const emotes = await fetchCustomEmojis(videoId);
return new Response(
JSON.stringify({ success: true, videoId, emotes }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
// Cache for 5 minutes — emotes don't change mid-stream
'Cache-Control': 'public, max-age=300',
},
}
);
} catch (error) {
console.error('YouTube emotes fetch error:', error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Failed to fetch emotes',
emotes: [],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
// Return 200 with empty list instead of 500 — missing emotes is non-fatal
);
}
};
async function fetchCustomEmojis(videoId: string): Promise<YTEmote[]> {
// Step 1: Fetch the watch page — contains ytInitialData with customEmojis
const watchPageUrl = `https://www.youtube.com/watch?v=${videoId}`;
const pageRes = await fetch(watchPageUrl, {
headers: {
// Mimic a browser so YouTube returns the full JS-embedded data
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (!pageRes.ok) {
throw new Error(`Failed to fetch YouTube watch page: ${pageRes.status}`);
}
const html = await pageRes.text();
// Step 2: Extract ytInitialData JSON from the page
// YouTube embeds it as: var ytInitialData = {...};
const initDataMatch = html.match(/var ytInitialData\s*=\s*(\{.+?\});\s*(?:var |<\/script>)/s);
if (!initDataMatch) {
// Try the live-chat iframe URL instead, which also has customEmojis
return fetchCustomEmojisFromChatFrame(videoId, html);
}
let ytInitialData: any;
try {
ytInitialData = JSON.parse(initDataMatch[1]);
} catch {
return fetchCustomEmojisFromChatFrame(videoId, html);
}
// Step 3: Walk ytInitialData to find customEmojis.
// They live at: contents.liveChatRenderer.customEmojis (in chat embed data)
// or inside engagementPanels → liveChatRenderer
const emotes = extractCustomEmojisFromInitData(ytInitialData);
if (emotes.length > 0) return emotes;
// Fallback: try the dedicated live-chat frame
return fetchCustomEmojisFromChatFrame(videoId, html);
}
/**
* Fallback: fetch https://www.youtube.com/live_chat?v=xxx which is the
* iframe YouTube embeds for chat. Its ytInitialData has a liveChatRenderer
* with customEmojis at the top level.
*/
async function fetchCustomEmojisFromChatFrame(
videoId: string,
_watchHtml?: string
): Promise<YTEmote[]> {
const chatFrameUrl = `https://www.youtube.com/live_chat?v=${videoId}&embed_domain=www.youtube.com`;
const res = await fetch(chatFrameUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
},
});
if (!res.ok) return [];
const html = await res.text();
// ytInitialData is embedded the same way in the chat frame
const match = html.match(/var ytInitialData\s*=\s*(\{.+?\});\s*(?:var |<\/script>)/s);
if (!match) return [];
let data: any;
try {
data = JSON.parse(match[1]);
} catch {
return [];
}
return extractCustomEmojisFromInitData(data);
}
/**
* Recursively finds any `customEmojis` array in ytInitialData and converts
* each entry to our YTEmote shape.
*
* YouTube's customEmoji entries look like:
* {
* emojiId: "UgkX...",
* shortcuts: [":channelName_emote1:"],
* searchTerms: [...],
* image: { thumbnails: [{ url, width, height }, ...], accessibility: ... },
* isCustomEmoji: true
* }
*/
function extractCustomEmojisFromInitData(data: any): YTEmote[] {
// BFS search for a `customEmojis` key anywhere in the tree
const queue: any[] = [data];
const results: YTEmote[] = [];
const seen = new Set<string>();
while (queue.length > 0) {
const node = queue.shift();
if (!node || typeof node !== 'object') continue;
if (Array.isArray(node.customEmojis)) {
for (const emoji of node.customEmojis) {
const emote = parseCustomEmoji(emoji);
if (emote && !seen.has(emote.name)) {
seen.add(emote.name);
results.push(emote);
}
}
// Don't stop — there might be more (e.g. multiple renderers)
}
// Enqueue child nodes (limit depth to avoid huge traversals)
for (const key of Object.keys(node)) {
const child = node[key];
if (child && typeof child === 'object') {
queue.push(child);
}
}
}
return results;
}
function parseCustomEmoji(emoji: any): YTEmote | null {
if (!emoji || typeof emoji !== 'object') return null;
// Pick the best image URL: prefer a mid-size thumbnail
const thumbnails: any[] = emoji.image?.thumbnails ?? [];
if (thumbnails.length === 0) return null;
// Sort ascending by width and pick the smallest that is >= 32px, else the largest
const sorted = [...thumbnails].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
const preferred = sorted.find((t) => (t.width ?? 0) >= 32) ?? sorted[sorted.length - 1];
const url: string = preferred?.url ?? '';
if (!url) return null;
// Name: prefer the shortcut code (e.g. ":channelName_hi:"), fall back to emojiId
const shortcuts: string[] = emoji.shortcuts ?? [];
const name = shortcuts[0] || emoji.emojiId || '';
if (!name) return null;
return {
name,
url,
isCustomEmoji: true,
};
}

View File

@@ -0,0 +1,359 @@
import type { APIRoute } from "astro";
// Simple in-memory cache and in-flight dedupe to avoid excessive YouTube API calls.
const CHANNEL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
const LIVE_CACHE_TTL = 1000 * 60 * 10; // 10 minutes
const channelResolveCache = new Map<
string,
{ channelId: string; expires: number }
>();
const liveVideoCache = new Map<
string,
{ videoId: string | null; expires: number }
>();
const inFlightChannelResolves = new Map<string, Promise<string | null>>();
const inFlightLiveFinds = new Map<string, Promise<string | null>>();
// Resolve a YouTube channel URL to its active live stream video ID.
// We use the Data API v3 and some local caching to save on quota.
export const GET: APIRoute = async ({ url, request }) => {
const headers = {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, s-maxage=600",
};
try {
const channelUrl = url.searchParams.get("channelUrl");
// Check Authorization header for accessToken
const authHeader = url.searchParams.get("accessToken") || (typeof request !== 'undefined' ? (request as any).headers.get("Authorization") : null);
const accessToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : authHeader;
if (!channelUrl) {
return new Response(
JSON.stringify({ error: "channelUrl parameter is required" }),
{ status: 400, headers },
);
}
const apiKey = import.meta.env.PUBLIC_YOUTUBE_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({
error: "YouTube API key not configured on the server",
}),
{ status: 500, headers },
);
}
// 1. Resolve the channel URL to a channel ID
const channelId = await resolveChannelId(channelUrl, apiKey);
if (!channelId) {
return new Response(
JSON.stringify({
error: "Could not resolve YouTube channel from the given URL",
}),
{ status: 404, headers },
);
}
// 2. If we have an access token, check if it belongs to this channel
// This allows finding unlisted or private streams for the logged-in user.
if (accessToken) {
try {
const myChannelId = await getMyChannelId(accessToken);
if (myChannelId === channelId) {
const liveId = await findMyLiveVideoId(accessToken);
if (liveId) {
return new Response(JSON.stringify({ videoId: liveId, channelId }), {
status: 200,
headers,
});
}
}
} catch (e) {
console.warn("Auth-based live check failed:", e);
}
}
// 3. Search for an active live stream on that channel (public)
const videoId = await findLiveVideoId(channelId, apiKey);
if (!videoId) {
// Return 200 with null videoId instead of 404 to avoid console errors.
// 404 is still technically correct (the live stream resource isn't there),
// but it causes noise in browser developer tools for periodic polling.
return new Response(
JSON.stringify({
videoId: null,
error: "No active live stream found for this channel",
channelId,
}),
{ status: 200, headers },
);
}
return new Response(JSON.stringify({ videoId, channelId }), {
status: 200,
headers,
});
} catch (error) {
console.error("youtube-live API error:", error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal server error",
}),
{ status: 500, headers },
);
}
};
/**
* Uses a YouTube access token to find the authorized user's channel ID.
*/
async function getMyChannelId(accessToken: string): Promise<string | null> {
const res = await fetch(
"https://www.googleapis.com/youtube/v3/channels?part=id&mine=true",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) return null;
const data = await res.json();
if (data.items && data.items.length > 0) {
return data.items[0].id;
}
return null;
}
/**
* Uses a YouTube access token to find the currently active live broadcast for the authorized user.
*/
async function findMyLiveVideoId(accessToken: string): Promise<string | null> {
const res = await fetch(
"https://www.googleapis.com/youtube/v3/liveBroadcasts?part=id&broadcastStatus=active&mine=true",
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!res.ok) {
if (res.status === 401) throw new Error("Unauthorized");
return null;
}
const data = await res.json();
if (data.items && data.items.length > 0) {
// Return the video ID (the broadcast ID is identical to the video ID)
return data.items[0].id;
}
return null;
}
// Helper to resolve different styles of YouTube URLs (handles, channel IDs, etc)
// and return the core channel ID.
async function resolveChannelId(
channelUrl: string,
apiKey: string,
): Promise<string | null> {
const now = Date.now();
// Return cached mapping if available
const cached = channelResolveCache.get(channelUrl);
if (cached && cached.expires > now) {
return cached.channelId;
}
// Deduplicate concurrent resolves for the same URL
if (inFlightChannelResolves.has(channelUrl)) {
return await inFlightChannelResolves.get(channelUrl)!;
}
const promise = (async () => {
let parsed: URL;
try {
parsed = new URL(channelUrl);
} catch {
return null;
}
const pathname = parsed.pathname; // e.g. /@lunateac or /channel/UCxyz
// Direct channel ID
if (pathname.startsWith("/channel/")) {
const id = pathname.replace("/channel/", "").split("/")[0];
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
// Handle-based URL (@handle)
if (pathname.startsWith("/@")) {
const handle = pathname.replace("/@", "").split("/")[0];
const id = await resolveHandleViaApi(handle, apiKey);
if (id)
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
// /c/CustomName or /user/Username try forUsername first, then search
if (pathname.startsWith("/c/") || pathname.startsWith("/user/")) {
const name = pathname.split("/")[2];
const id = await resolveByUsernameOrSearch(name, apiKey);
if (id)
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
// Fallback: treat last segment as possible handle
const segments = pathname.split("/").filter(Boolean);
if (segments.length > 0) {
const id = await resolveHandleViaApi(
segments[segments.length - 1],
apiKey,
);
if (id)
channelResolveCache.set(channelUrl, {
channelId: id,
expires: now + CHANNEL_CACHE_TTL,
});
return id;
}
return null;
})();
inFlightChannelResolves.set(channelUrl, promise);
try {
return await promise;
} finally {
inFlightChannelResolves.delete(channelUrl);
}
}
async function resolveHandleViaApi(
handle: string,
apiKey: string,
): Promise<string | null> {
// YouTube Data API v3 channels.list with forHandle
const res = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=id&forHandle=${encodeURIComponent(handle)}&key=${apiKey}`,
);
if (!res.ok) {
console.warn(`YouTube API channels.list forHandle failed: ${res.status}`);
// Fallback to search
return resolveByUsernameOrSearch(handle, apiKey);
}
const data = await res.json();
if (data.items && data.items.length > 0) {
return data.items[0].id;
}
// Fallback to search
return resolveByUsernameOrSearch(handle, apiKey);
}
async function resolveByUsernameOrSearch(
name: string,
apiKey: string,
): Promise<string | null> {
// Try forUsername
const res = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=id&forUsername=${encodeURIComponent(name)}&key=${apiKey}`,
);
if (res.ok) {
const data = await res.json();
if (data.items && data.items.length > 0) {
return data.items[0].id;
}
}
// Last resort: search for the channel name
const searchRes = await fetch(
`https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(name)}&type=channel&maxResults=1&key=${apiKey}`,
);
if (searchRes.ok) {
const searchData = await searchRes.json();
if (searchData.items && searchData.items.length > 0) {
return searchData.items[0].snippet.channelId;
}
}
return null;
}
// Check if a channel actually has a live stream running right now
async function findLiveVideoId(
channelId: string,
apiKey: string,
): Promise<string | null> {
const now = Date.now();
// Check cache first
const cached = liveVideoCache.get(channelId);
if (cached && cached.expires > now) {
return cached.videoId;
}
// Dedupe concurrent lookups
if (inFlightLiveFinds.has(channelId)) {
return await inFlightLiveFinds.get(channelId)!;
}
const promise = (async () => {
const res = await fetch(
`https://www.googleapis.com/youtube/v3/search?part=id&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
);
if (!res.ok) {
console.warn(`YouTube search for live video failed: ${res.status}`);
// Cache negative result for a short period to avoid thundering retry
liveVideoCache.set(channelId, {
videoId: null,
expires: now + LIVE_CACHE_TTL,
});
return null;
}
const data = await res.json();
if (data.items && data.items.length > 0) {
const vid = data.items[0].id.videoId;
liveVideoCache.set(channelId, {
videoId: vid,
expires: now + LIVE_CACHE_TTL,
});
return vid;
}
// No live video found — cache null for a short period
liveVideoCache.set(channelId, {
videoId: null,
expires: now + LIVE_CACHE_TTL,
});
return null;
})();
inFlightLiveFinds.set(channelId, promise);
try {
return await promise;
} finally {
inFlightLiveFinds.delete(channelId);
}
}

View File

@@ -0,0 +1,243 @@
import type { APIRoute } from 'astro';
import { LiveChat } from 'youtube-chat';
// Handle YouTube chat via the youtube-chat library.
// We cache the chat connection in a global to avoid firing it up on every request.
interface MessagePart {
type: 'text' | 'emoji';
text?: string; // for text parts
url?: string; // for emoji/emote parts
alt?: string; // for emoji/emote parts
emojiText?: string; // shortcode like ":slightly_smiling_face:"
isCustomEmoji?: boolean;
}
interface ChatMessage {
id: string;
author: string;
authorAvatar?: string;
badges?: Record<string, string>;
parts: MessagePart[];
timestamp: Date;
superchat?: {
amount: string;
color: string;
sticker?: {
url: string;
alt: string;
};
};
}
interface YTEmoteEntry {
name: string;
url: string;
isCustomEmoji: boolean;
}
// Global map to store active LiveChat instances and their message caches
const activeLiveChats = new Map<
string,
{
chat: LiveChat;
messages: ChatMessage[];
/** Deduplicated emote registry: name → entry */
emoteMap: Map<string, YTEmoteEntry>;
lastUpdate: number;
}
>();
export const GET: APIRoute = async ({ url }) => {
const videoId = url.searchParams.get('videoId');
if (!videoId) {
return new Response(
JSON.stringify({ error: 'videoId query parameter is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
try {
let chatSession = activeLiveChats.get(videoId);
// Create a new LiveChat session if one doesn't exist
if (!chatSession) {
// Use a lock-like mechanism to prevent duplicate sessions from concurrent requests
const chat = new LiveChat({
liveId: videoId,
});
const messages: ChatMessage[] = [];
const emoteMap = new Map<string, YTEmoteEntry>();
// Handle incoming messages
chat.on('chat', (chatItem: any) => {
// Use the library's message ID if available, otherwise generate a stable one
// chatItem.id is the actual unique ID from YouTube if the library provides it.
const messageId = chatItem.id || `${chatItem.author?.channelId || 'anon'}-${chatItem.timestamp?.getTime() || Date.now()}`;
// Deduplicate messages by ID to prevent duplicates if the scraper re-emits them
if (messages.some(m => m.id === messageId)) {
return;
}
// Convert raw message parts into our typed MessagePart array.
const parts: MessagePart[] = (chatItem.message || []).map((msg: any) => {
if (msg.url) {
const name = msg.emojiText || msg.alt || msg.url;
if (!emoteMap.has(name)) {
emoteMap.set(name, {
name,
url: msg.url,
isCustomEmoji: !!msg.isCustomEmoji,
});
}
return {
type: 'emoji' as const,
url: msg.url,
alt: msg.alt || msg.emojiText || '',
emojiText: msg.emojiText || '',
isCustomEmoji: !!msg.isCustomEmoji,
};
}
return { type: 'text' as const, text: msg.text ?? String(msg) };
});
const message: ChatMessage = {
id: messageId,
author: chatItem.author.name || 'Anonymous',
authorAvatar: chatItem.author.thumbnail?.url,
badges: chatItem.author.badge ? { [chatItem.author.badge.title]: chatItem.author.badge.thumbnail?.url } : undefined,
parts,
timestamp: chatItem.timestamp || new Date(),
superchat: chatItem.superchat ? {
amount: chatItem.superchat.amount,
color: chatItem.superchat.color,
sticker: chatItem.superchat.sticker ? {
url: chatItem.superchat.sticker.url,
alt: chatItem.superchat.sticker.alt,
} : undefined,
} : undefined,
};
messages.push(message);
// Keep only last 200 messages
if (messages.length > 200) {
messages.shift();
}
});
// Handle errors
chat.on('error', (err: any) => {
console.error('YouTube LiveChat error:', err);
// Clean up failed session after 30 seconds
setTimeout(() => {
if (activeLiveChats.get(videoId)?.chat === chat) {
activeLiveChats.delete(videoId);
}
}, 30000);
});
// Handle stream end
chat.on('end', () => {
console.log('YouTube stream ended for videoId:', videoId);
if (activeLiveChats.get(videoId)?.chat === chat) {
activeLiveChats.delete(videoId);
}
});
// Start the chat connection
const ok = await chat.start();
if (!ok) {
return new Response(
JSON.stringify({ error: 'Failed to connect to YouTube chat' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
chatSession = {
chat,
messages,
emoteMap,
lastUpdate: Date.now(),
};
// Check if another request beat us to it while we were waiting for chat.start()
const existing = activeLiveChats.get(videoId);
if (existing) {
// If an existing session exists, stop this one to avoid double scraping
chat.stop();
chatSession = existing;
} else {
activeLiveChats.set(videoId, chatSession);
console.log(`Created LiveChat session for videoId: ${videoId}`);
}
// Try to get channel emotes in the background so autocomplete is snappy.
// This pulls from the initial data scrape so users don't have to wait for them to appear in chat.
(async () => {
try {
// Build an absolute URL from the current request's origin
const origin = url.origin;
const emoteRes = await fetch(
`${origin}/api/youtube-emotes?videoId=${encodeURIComponent(videoId)}`
);
if (emoteRes.ok) {
const emoteData = await emoteRes.json();
if (Array.isArray(emoteData.emotes)) {
const session = activeLiveChats.get(videoId);
if (session) {
for (const e of emoteData.emotes) {
if (e.name && e.url && !session.emoteMap.has(e.name)) {
session.emoteMap.set(e.name, {
name: e.name,
url: e.url,
isCustomEmoji: true,
});
}
}
console.log(
`Pre-loaded ${emoteData.emotes.length} channel emotes for videoId: ${videoId}`
);
}
}
}
} catch (err) {
// Non-fatal — emotes will still accumulate from chat messages
console.warn('Failed to pre-load YouTube emotes:', err);
}
})();
}
// Update last access time
chatSession.lastUpdate = Date.now();
return new Response(
JSON.stringify({
success: true,
videoId,
messages: chatSession.messages,
messageCount: chatSession.messages.length,
// All unique emotes/emoji seen so far across all messages
emotes: Array.from(chatSession.emoteMap.values()),
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
},
}
);
} catch (error) {
console.error('YouTube stream chat API error:', error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Internal server error',
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,142 @@
import type { APIRoute } from "astro";
import { getTwitchAccessToken, getTwitchUser } from "../../../lib/twitch";
export const GET: APIRoute = async ({ url, cookies }) => {
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
if (!code) {
return new Response("Authorization code not found", { status: 400 });
}
// The state value is validated client-side (in the inline script) because
// sessionStorage is only accessible in the browser. We still require it to
// be present on the server so a stateless request is always rejected.
if (!returnedState) {
return new Response("Missing state parameter", { status: 400 });
}
try {
const isDev = import.meta.env.MODE === 'development';
const isSecure = !isDev;
const clientId = import.meta.env.PUBLIC_TWITCH_CLIENT_ID;
const clientSecret = import.meta.env.TWITCH_CLIENT_SECRET;
const redirectUri = import.meta.env.PUBLIC_TWITCH_REDIRECT_URI;
const tokenData = await getTwitchAccessToken(
code,
clientId,
clientSecret,
redirectUri,
);
const user = await getTwitchUser(tokenData.accessToken, clientId);
// Store token in secure cookie
cookies.set("twitch_token", tokenData.accessToken, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: tokenData.expiresIn || 3600,
path: "/",
});
if (tokenData.refreshToken) {
cookies.set("twitch_refresh_token", tokenData.refreshToken, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
}
// Safely serialize all values with JSON.stringify so that special characters
// (quotes, backslashes, newlines, etc.) in any user-controlled field can never
// break out of the JavaScript context and execute arbitrary code.
const sessionPayload = JSON.stringify({
platform: "twitch",
userId: user.userId,
displayName: user.displayName,
username: user.login,
profileImageUrl: user.profileImageUrl,
accessToken: tokenData.accessToken,
refreshToken: tokenData.refreshToken ?? "",
expiresAt: Date.now() + ((tokenData.expiresIn ?? 3600) * 1000),
timestamp: Date.now(),
});
// Pass the returned state value safely so the browser can verify it.
const returnedStateJson = JSON.stringify(returnedState);
// Return HTML that stores session in localStorage, posts message to opener, and redirects
return new Response(
`<!DOCTYPE html>
<html>
<head><title>Twitch Auth</title></head>
<body>
<script>
(function () {
// --- CSRF state validation ---
var expectedState = sessionStorage.getItem('twitch_oauth_state');
var returnedState = ${returnedStateJson};
sessionStorage.removeItem('twitch_oauth_state');
if (!expectedState || expectedState !== returnedState) {
document.body.innerText = 'Authentication failed: invalid state. Possible CSRF attack.';
return;
}
// --- End CSRF check ---
var session = ${sessionPayload};
try {
var sessions = JSON.parse(localStorage.getItem('mixchat_sessions') || '{}');
// Strip accessToken: it lives only in memory (React state) and is
// repopulated on reload via useTokenRefresh + the httpOnly cookie.
var persisted = Object.assign({}, session);
delete persisted.accessToken;
sessions.twitch = persisted;
localStorage.setItem('mixchat_sessions', JSON.stringify(sessions));
} catch (e) { /* storage unavailable */ }
try {
if (window.opener && window.opener.postMessage) {
window.opener.postMessage({ type: 'mixchat_session', session: session }, window.location.origin);
}
} catch (e) { /* cross-origin opener */ }
// If opened as a popup, close after notifying opener; otherwise redirect
if (window.opener) {
try { window.close(); } catch (e) {}
// fallback redirect in case close() is blocked
window.location.href = '/';
} else {
window.location.href = '/';
}
})();
<\/script>
</body>
</html>`,
{
status: 200,
headers: { "Content-Type": "text/html" },
},
);
} catch (error) {
console.error("Twitch OAuth callback error:", error);
// Return a generic error page — never reflect raw error messages to the client.
return new Response(
`<!DOCTYPE html>
<html>
<head><title>Auth Error</title></head>
<body>
<h1>Authentication Failed</h1>
<p>An error occurred during authentication. Please try again.</p>
<a href="/">Back to home</a>
</body>
</html>`,
{ status: 500, headers: { "Content-Type": "text/html" } },
);
}
};

View File

@@ -0,0 +1,169 @@
import type { APIRoute } from "astro";
import { getYoutubeAccessToken, getYoutubeUser } from "../../../lib/youtube";
export const GET: APIRoute = async ({ url, cookies }) => {
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
if (!code) {
return new Response("Authorization code not found", { status: 400 });
}
// Require the state parameter to be present on every callback request.
if (!returnedState) {
return new Response("Missing state parameter", { status: 400 });
}
try {
const isDev = import.meta.env.MODE === 'development';
const isSecure = !isDev;
const clientId = import.meta.env.PUBLIC_YOUTUBE_CLIENT_ID;
const clientSecret = import.meta.env.YOUTUBE_CLIENT_SECRET;
const redirectUri = import.meta.env.PUBLIC_YOUTUBE_REDIRECT_URI;
const tokenData = await getYoutubeAccessToken(
code,
clientId,
clientSecret,
redirectUri,
);
const user = await getYoutubeUser(tokenData.access_token);
// Store token in secure cookie. Align settings with the refresh endpoint
// so the server can read the refresh token later and refresh seamlessly.
cookies.set("youtube_token", tokenData.access_token, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: tokenData.expires_in || 3600,
path: "/",
});
if (tokenData.refresh_token) {
cookies.set("youtube_refresh_token", tokenData.refresh_token, {
httpOnly: true,
secure: isSecure,
sameSite: "lax",
maxAge: 365 * 24 * 60 * 60, // 365 days
path: "/",
});
}
// Safely serialize all values with JSON.stringify so that special characters
// (quotes, backslashes, newlines, etc.) in any user-controlled field can never
// break out of the JavaScript context and execute arbitrary code.
const sessionPayload = JSON.stringify({
platform: "youtube",
userId: user.userId,
displayName: user.displayName,
profileImageUrl: user.profileImageUrl,
accessToken: tokenData.access_token,
// Only include refreshToken when Google actually returns one.
// An empty string would be falsy and cause useTokenRefresh to skip
// the httpOnly-cookie-based token refresh on page reload.
...(tokenData.refresh_token ? { refreshToken: tokenData.refresh_token } : {}),
expiresAt: Date.now() + ((tokenData.expires_in ?? 3600) * 1000),
timestamp: Date.now(),
});
// Pass the returned state value safely so the browser can verify it.
const returnedStateJson = JSON.stringify(returnedState);
// Return HTML that posts the session to the opener (popup flow) and
// falls back to a localStorage + redirect approach when opened as a
// full-page redirect (e.g. popup blocked).
return new Response(
`<!DOCTYPE html>
<html>
<head><title>YouTube Auth</title></head>
<body>
<script>
(function () {
var returnedState = ${returnedStateJson};
// --- CSRF state validation ---
// In a popup flow the sessionStorage is scoped to the popup browsing context.
// After navigating through an external OAuth provider (Google) the popup's
// sessionStorage may no longer contain the key that was set before the navigation.
// Try to read the expected state from the opener's sessionStorage first
// (same-origin access is allowed), then fall back to the local copy.
var expectedState = null;
try {
if (window.opener && window.opener.sessionStorage) {
expectedState = window.opener.sessionStorage.getItem('youtube_oauth_state');
window.opener.sessionStorage.removeItem('youtube_oauth_state');
}
} catch (e) { /* opener may be cross-origin or inaccessible */ }
if (!expectedState) {
// Fallback: redirect flow — state lives in this window's sessionStorage.
expectedState = sessionStorage.getItem('youtube_oauth_state');
sessionStorage.removeItem('youtube_oauth_state');
}
if (!expectedState || expectedState !== returnedState) {
document.body.innerText = 'Authentication failed: invalid state. Possible CSRF attack.';
return;
}
// --- End CSRF check ---
var session = ${sessionPayload};
try {
var sessions = JSON.parse(localStorage.getItem('mixchat_sessions') || '{}');
// Strip accessToken: it lives only in memory (React state) and is
// repopulated on reload via useTokenRefresh + the httpOnly cookie.
var persisted = Object.assign({}, session);
delete persisted.accessToken;
sessions.youtube = persisted;
localStorage.setItem('mixchat_sessions', JSON.stringify(sessions));
} catch (e) { /* storage unavailable */ }
// Signal the parent window via postMessage (if opener reference is alive)
// and also via a dedicated localStorage key as a reliable fallback.
// Some browsers null out window.opener after cross-origin navigation.
try {
localStorage.setItem('mixchat_youtube_auth', JSON.stringify({ session: session, ts: Date.now() }));
} catch (e) { /* storage unavailable */ }
try {
if (window.opener && window.opener.postMessage) {
window.opener.postMessage({ type: 'mixchat_session', session: session }, window.location.origin);
}
} catch (e) { /* cross-origin opener */ }
// Close the popup if we were opened as one; otherwise do a full redirect.
if (window.opener) {
try { window.close(); } catch (e) {}
// fallback redirect in case close() is blocked
window.location.href = '/';
} else {
window.location.href = '/';
}
})();
<\/script>
</body>
</html>`,
{
status: 200,
headers: { "Content-Type": "text/html" },
},
);
} catch (error) {
console.error("YouTube OAuth callback error:", error);
// Return a generic error page — never reflect raw error messages to the client.
return new Response(
`<!DOCTYPE html>
<html>
<head><title>Auth Error</title></head>
<body>
<h1>Authentication Failed</h1>
<p>An error occurred during authentication. Please try again.</p>
<a href="/">Back to home</a>
</body>
</html>`,
{ status: 500, headers: { "Content-Type": "text/html" } },
);
}
};

156
src/pages/index.astro Normal file
View File

@@ -0,0 +1,156 @@
---
import AppContainer from "../components/AppContainer";
import { DISPLAY_VERSION } from "../lib/version";
import "../styles/global.css";
import "../styles/AppContainer.css";
import "../styles/ChatDisplay.css";
import "../styles/LoginPanel.css";
import "../styles/MessageInput.css";
import "../styles/Notification.css";
import "../styles/LoadingOverlay.css";
import "../styles/EmoteMenu.css";
const title = "Mixchat - Advanced Twitch Chat Client";
const description =
"Enhanced Twitch chat client with support for emotes from 7TV, BTTV, and FFZ. Stream replies, badges, and third-party integrations.";
const mainUrl = "https://mixchat.grepfs.xyz/";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="theme-color" content="#0a0a0a" />
<meta name="mobile-web-app-capable" content="true" />
<title>{title}</title>
<link rel="icon" type="image/x-icon" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content={mainUrl} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:site_name" content="Mixchat" />
<meta property="og:image" content={mainUrl + "favicon.png"} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={mainUrl + "favicon.png"} />
<meta
name="keywords"
content="twitch chat, twitch emotes, 7tv, bttv, ffz, streaming client, chat client, youtube chat, youtube emotes, youtube chat client, mixchat"
/>
<meta name="author" content="Mixchat" />
<link rel="canonical" href={mainUrl} />
<link rel="preconnect" href="https://www.twitch.tv" />
<link rel="preconnect" href="https://www.youtube.com" />
<script
type="application/ld+json"
set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "WebApplication",
name: "Mixchat",
description: description,
applicationCategory: "Utility",
})}
/>
</head>
<style>
#app {
height: 100vh;
padding: 0;
padding-bottom: 50px;
background: var(--color-bg-primary);
overflow: hidden;
}
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
border-top: 1px solid var(--color-border-medium);
padding: 12px 20px;
background: var(--color-bg-primary);
font-size: 12px;
color: var(--color-text-secondary);
text-align: center;
z-index: 100;
height: 50px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
footer img {
position: absolute;
left: 20px;
opacity: 0.7;
}
footer a {
color: var(--color-accent);
text-decoration: none;
margin: 0 12px;
}
footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
#app {
padding: 0;
padding-bottom: 60px;
}
footer {
height: 60px;
padding: 8px 12px;
font-size: 11px;
}
footer img {
display: none;
}
footer a {
margin: 0 6px;
}
}
</style>
</html>
<body>
<div id="app">
<AppContainer client:load />
</div>
<footer>
<a href="/privacy">Privacy</a>
<a href="/terms">Terms</a>
<span
>© 2026 Mixchat by <a
href="https://github.com/grepfs17/Mixchat"
target="_blank">grepfs17</a
> — Licensed under <a
href="https://opensource.org/licenses/MIT"
target="_blank">MIT</a
> — {DISPLAY_VERSION}</span
>
<img
src="/logo.png"
alt="Mixchat Logo"
style="width: auto; height: 30px;"
/>
</footer>
</body>

164
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,164 @@
---
import "../styles/global.css";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Mixchat Privacy Policy" />
<title>Privacy Policy - Mixchat</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
max-width: 900px;
margin: 0 auto;
padding: 20px;
background: #0a0a0a;
color: #e0e0e0;
}
h1 {
color: #fff;
margin-bottom: 10px;
}
h2 {
color: var(--color-accent);
margin-top: 30px;
}
.meta {
color: #999;
font-size: 14px;
margin-bottom: 30px;
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", monospace;
}
</style>
</head>
<body>
<div style="text-align: center; margin-bottom: 30px;">
<img
src="/logo.png"
alt="Mixchat Logo"
style="width: auto; height: 80px;"
/>
</div>
<h1>Privacy Policy</h1>
<p class="meta">Last updated: March 29, 2026</p>
<h2>1. Overview</h2>
<p>
Mixchat is designed to function without a server-side database. No
messages, emails, or account credentials are stored on any server. Data
processing occurs locally in the visitor's browser. Once the user logs out
or clears their cache, session data is removed.
</p>
<h2>2. Data Collection</h2>
<h3>2.1 Logging In (Twitch & YouTube)</h3>
<p>
When connecting a Twitch or YouTube account, the browser gets "keys"
(OAuth tokens) from those services.
</p>
<ul>
<li>
<strong>Secure Storage:</strong> Key tokens are stored as <code
>httpOnly</code
> cookies. This helps block outside scripts from seeing them.
</li>
<li>
<strong>UI Session:</strong> A little bit of info (like your username and
profile picture) is saved in <code>localStorage</code> so the app remembers
who you are on refresh.
</li>
</ul>
<h3>2.2 Your Messages</h3>
<p>
Mixchat is a real-time client. Chat messages flow from Twitch or YouTube
directly to the screen. They are kept in the browser's memory during a
session and are not archived.
</p>
<h3>2.3 YouTube Integration</h3>
<p>
By linking a YouTube channel, Mixchat uses the official YouTube Data API.
It only requests public info to show live status and stream metadata
properly.
</p>
<h2>3. Third-Party Services</h2>
<p>
To make everything work smoothly, Mixchat talks to a few core services:
</p>
<ul>
<li><strong>Twitch & YouTube:</strong> For the chat and video feeds.</li>
<li>
<strong>Emote Providers (7TV, BTTV, FFZ):</strong> To render those custom
emotes everyone loves. These services might see which channel you're watching
so they can serve the right emote pack.
</li>
</ul>
<h2>4. Stateless and Database-Free</h2>
<p>
Mixchat's server/API is "stateless." This means it doesn't remember
sessions from one second to the next without the browser sending its
security key. There's no backend database where history or personal info
could be stored.
</p>
<h2>5. Data Retention and Deletion</h2>
<p>
Since no data is stored, "deleting an account" is straightforward.
Clicking
<strong>Logout</strong> wipes the cookies and session info from the browser.
Closing the tab clears the memory usage. For total assurance, clear the site
data in the browser settings.
</p>
<h2>6. Security</h2>
<p>
Login tokens are managed using secure HTTP-only cookies. It is recommended
to use strong passwords and two-factor authentication (2FA) on Twitch and
YouTube accounts.
</p>
<h2>7. Changes</h2>
<p>
Mixchat changes fast. The date at the top will be updated if there are
ever changes to how info is handled (like adding a new feature that needs
a different API).
</p>
<h2>8. Contact</h2>
<p>
If you have questions about any of this, open an issue on the
<a href="https://github.com/grepfs17/Mixchat" target="_blank"
>GitHub repository</a
>.
</p>
<hr style="margin-top: 50px; border: none; border-top: 1px solid #333;" />
<p
style="text-align: center; color: #666; font-size: 12px; margin-top: 20px;"
>
<a href="/">Back to Mixchat</a>
</p>
</body>
</html>

125
src/pages/terms.astro Normal file
View File

@@ -0,0 +1,125 @@
---
import "../styles/global.css";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Mixchat Terms of Service" />
<title>Terms of Service - Mixchat</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
line-height: 1.6;
max-width: 900px;
margin: 0 auto;
padding: 20px;
background: #0a0a0a;
color: #e0e0e0;
}
h1 {
color: #fff;
margin-bottom: 10px;
}
h2 {
color: var(--color-accent);
margin-top: 30px;
}
.meta {
color: #999;
font-size: 14px;
margin-bottom: 30px;
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", monospace;
}
</style>
</head>
<body>
<div style="text-align: center; margin-bottom: 30px;">
<img
src="/logo.png"
alt="Mixchat Logo"
style="width: auto; height: 80px;"
/>
</div>
<h1>Terms of Service</h1>
<p class="meta">Last updated: March 29, 2026</p>
<h2>1. Disclaimer</h2>
<p>Mixchat is an unofficial app with no connection to Twitch or YouTube.</p>
<p>
The app is provided "as is." If it breaks, or if platforms change their
APIs, Mixchat is not liable for service interruptions or account impacts.
</p>
<h2>2. User Guidelines</h2>
<p>By using Mixchat, you're promising to:</p>
<ul>
<li>
Follow the official <a
href="https://www.twitch.tv/legal/terms-of-service"
target="_blank">Twitch Terms</a
> and <a
href="https://www.twitch.tv/legal/community-guidelines"
target="_blank">Community Guidelines</a
>.
</li>
<li>Don't use the app to spam, harass, or crawl data unfairly.</li>
<li>Don't try to break the app or its API endpoints.</li>
<li>
If you link your YouTube, follow <a
href="https://www.youtube.com/t/terms"
target="_blank">Google's rules</a
> too.
</li>
</ul>
<h2>3. Limited Liability</h2>
<p>
If data, followers, or other account assets are lost due to bugs or
downtime, Mixchat is not responsible. The project is maintained by the
community on GitHub.
</p>
<h2>4. Open Source & License</h2>
<p>
Mixchat is open source and licensed under the MIT License. Feel free to
peek at the code or contribute. Just respect the trademarks of Twitch,
Google, 7TV, etc.
</p>
<h2>5. Termination</h2>
<p>
Mixchat reserves the right to block access or change the application at
any time if usage violates these terms.
</p>
<h2>6. Contact</h2>
<p>
Questions? Reach out on
<a href="https://github.com/grepfs17/Mixchat" target="_blank">GitHub</a>.
</p>
<hr style="margin-top: 50px; border: none; border-top: 1px solid #333;" />
<p
style="text-align: center; color: #666; font-size: 12px; margin-top: 20px;"
>
<a href="/">Back to Mixchat</a>
</p>
</body>
</html>

View File

@@ -0,0 +1,66 @@
@import './vars.css';
/* AppContainer responsive layout */
@media (max-width: 1365px) {
/* Main grid layout */
[style*="gridTemplateColumns"][style*="280px"] {
grid-template-columns: 1fr !important;
}
/* Sidebar becomes floating/overlay */
[data-sidebar-section] {
display: flex !important;
position: fixed !important;
left: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 280px !important;
z-index: 1000 !important;
box-shadow: var(--shadow-sm-dark) !important;
}
/* Show close button on mobile */
.sidebar-close-btn-container {
display: flex !important;
}
/* Video on top */
.app-grid {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 40vh 1fr !important;
gap: 0 !important;
}
/* When stream embed is hidden, adjust grid to full height for chat */
.app-grid:not(:has([data-stream-embed])) {
grid-template-rows: 1fr !important;
}
.app-grid > [data-stream-embed] {
grid-column: 1 !important;
grid-row: 1 !important;
min-height: 0 !important;
overflow: hidden !important;
height: auto !important;
}
/* Hide resizer */
.app-grid > div:nth-child(2) {
display: none !important;
}
/* Chat at bottom */
.app-grid > [data-chat-section] {
grid-column: 1 !important;
grid-row: 2 !important;
min-height: 0 !important;
overflow: auto !important;
}
[data-resizer] {
display: none !important;
}
}

588
src/styles/ChatDisplay.css Normal file
View File

@@ -0,0 +1,588 @@
@import './vars.css';
.chat-display {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-primary);
overflow: hidden;
box-shadow: var(--shadow-sm);
position: relative;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--color-bg-primary);
color: var(--color-text-white);
border-bottom: 1px solid var(--color-border-dark);
gap: 8px;
}
.header-button {
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-light);
color: var(--color-text-primary);
padding: 6px 12px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
margin-right: 8px;
}
.header-button:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-border-lighter);
color: var(--color-text-white);
}
.header-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chat-header-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.chat-header-content h2 {
flex-shrink: 0;
}
.chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-header h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.stream-title {
font-size: 0.75rem;
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
padding: 2px 6px;
border-radius: var(--radius-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.loading-indicator {
font-size: 1.5rem;
color: #4ade80;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.messages-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--chat-font-size, 0.9rem);
}
/* Custom scrollbar styling */
.messages-container::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track {
background: var(--color-bg-primary);
}
.messages-container::-webkit-scrollbar-thumb {
background: var(--color-border-medium);
border-radius: var(--radius-md);
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: var(--color-border-lighter);
}
.no-messages {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-muted);
font-style: italic;
}
.message {
background: var(--color-bg-secondary);
padding: 6px 8px;
border-left: 3px solid var(--color-border-medium);
transition: background-color 0.2s ease, border-left-color 0.2s ease;
cursor: pointer;
}
.message:hover {
background: #252525;
border-left-color: #444444;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-single-line {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--chat-font-size, 0.85rem);
flex-wrap: wrap;
}
.avatar {
width: 20px;
height: 20px;
object-fit: cover;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.author {
font-weight: 600;
flex-shrink: 0;
}
.message-separator {
color: #94a3b8;
flex-shrink: 0;
}
.message-text {
color: #cbd5e1;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.3;
font-size: var(--chat-font-size, 0.9rem);
flex: 1;
}
.message-actions {
display: flex;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.action-button {
background: transparent;
border: 1px solid #444444;
color: #cbd5e1;
cursor: pointer;
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
opacity: 0.8;
transition: all 0.2s ease;
flex-shrink: 0;
border-radius: var(--radius-sm);
}
.action-button:hover {
opacity: 1;
background: #2a2a2a;
border-color: #555555;
color: #e2e8f0;
}
.timestamp {
font-size: 0.7rem;
color: #94a3b8;
flex-shrink: 0;
}
.go-to-bottom-button {
position: absolute;
bottom: 8px;
right: 16px;
background: var(--color-accent);
border: 1px solid var(--color-accent-hover);
color: #ffffff;
padding: 8px 12px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
z-index: 100;
box-shadow: var(--shadow-accent-sm);
}
.go-to-bottom-button:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-accent-md);
transform: translateY(-2px);
}
.go-to-bottom-button:active {
transform: translateY(0);
}
.chat-emote {
position: relative;
}
.chat-emote-tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.95);
border: 1px solid #444;
border-radius: var(--radius-xl);
padding: 8px;
z-index: 10000;
pointer-events: none;
width: auto;
height: auto;
display: flex;
flex-direction: column;
gap: 6px;
animation: tooltipFade 0.2s ease-out;
}
.chat-emote-tooltip img {
width: 100%;
height: auto;
display: block;
border-radius: var(--radius-md);
object-fit: contain;
}
.chat-emote-tooltip-name {
color: #e2e8f0;
font-size: 0.85rem;
text-align: center;
word-break: break-word;
}
@keyframes tooltipFade {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.sidebar-toggle-button {
padding: 4px 8px;
background-color: #1a1a1a;
color: #e2e8f0;
border: 1px solid #333;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: background-color 0.2s;
}
.sidebar-toggle-button:hover {
background-color: #2a2a2a;
}
.show-stream-button {
padding: 4px 8px;
background-color: #6366f1;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: background-color 0.2s;
}
.show-stream-button:hover {
background-color: #4f46e5;
}
.status-badge-live {
display: inline-flex;
align-items: center;
gap: 4px;
background-color: #dc2626;
color: #ffffff;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75em;
font-weight: 600;
margin-right: 8px;
}
.status-badge-offline {
display: inline-flex;
align-items: center;
gap: 4px;
background-color: #6b7280;
color: #ffffff;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.75em;
font-weight: 600;
margin-right: 8px;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #ffffff;
}
.status-dot.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.channel-link {
color: #e2e8f0;
text-decoration: none;
transition: color 0.2s;
}
.channel-link:hover {
color: #9146ff;
}
.viewer-count-display {
margin-left: 12px;
font-size: 0.85em;
color: #94a3b8;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
.uptime-display {
margin-left: 12px;
font-size: 0.85em;
color: #94a3b8;
font-weight: 500;
}
.message.preloaded {
opacity: 0.55;
background-color: #0a0a0a;
}
.message.bot {
opacity: 0.6;
background-color: #0f0f0f;
}
.message.redemption,
.message.subscription,
.message.resub,
.message.subgift {
background-color: rgba(145, 71, 255, 0.4);
border-left: 3px solid #9147ff;
}
.message.cheer {
background-color: rgba(255, 183, 0, 0.4);
border-left: 3px solid #ffb700;
}
.message.mention {
background-color: rgba(99, 102, 241, 0.2);
border-left: 3px solid #6366f1;
padding-left: 8px;
margin-left: -8px;
}
.reply-info {
font-size: 0.75em;
color: #64748b;
margin-bottom: 2px;
font-style: italic;
opacity: 0.8;
display: flex;
align-items: center;
gap: 4px;
}
.reply-target-content {
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 450px;
display: inline-block;
vertical-align: bottom;
}
.reply-target-content .chat-emote {
height: 1.25em !important;
margin: 0 1px !important;
}
.author.self {
background-color: #3b82f6;
padding: 2px 6px;
border-radius: 3px;
}
.author-link {
text-decoration: none;
cursor: pointer;
}
.author-link:hover {
text-decoration: underline;
}
/* Responsive design for lower resolutions */
@media (max-width: 768px) {
.header-button {
font-size: 0;
gap: 0 !important;
}
.header-button svg {
display: inline;
}
.message-single-line {
gap: 2px;
}
.message {
padding: 4px 6px;
}
.avatar {
width: 16px;
height: 16px;
}
.author {
font-size: 0.8rem;
}
.timestamp {
font-size: 0.65rem;
}
.message-text {
font-size: 0.85rem;
margin-top: 2px;
flex-basis: 100%;
}
.message-actions {
margin-left: auto;
margin-top: 4px;
flex-basis: 100%;
}
}
@media (max-width: 480px) {
.message-single-line {
gap: 1px;
font-size: 0.75rem;
}
.message {
padding: 3px 4px;
gap: 2px;
}
.avatar {
width: 14px;
height: 14px;
}
.author {
font-size: 0.75rem;
}
.timestamp {
font-size: 0.6rem;
}
.message-text {
font-size: 0.8rem;
margin-top: 1px;
flex-basis: 100%;
}
.message-separator {
display: none;
}
.message-actions {
gap: 2px;
margin-top: 3px;
flex-basis: 100%;
margin-left: auto;
justify-content: flex-end;
}
.action-button {
padding: 2px 4px;
font-size: 10px;
}
}

311
src/styles/EmoteMenu.css Normal file
View File

@@ -0,0 +1,311 @@
@import './vars.css';
.emote-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.emote-menu-modal {
background: var(--color-bg-primary);
border: 1px solid var(--color-accent);
border-radius: var(--radius-2xl);
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-accent-lg);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.emote-menu-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--color-border-medium);
}
.emote-menu-header h2 {
margin: 0;
color: var(--color-text-primary);
font-size: 1.5rem;
}
.emote-menu-close {
background: transparent;
border: none;
color: var(--color-text-secondary);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.emote-menu-close:hover {
color: var(--color-text-primary);
}
.emote-menu-search {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-bottom: 1px solid var(--color-border-medium);
background: var(--color-bg-primary);
}
.emote-search-input {
flex: 1;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-radius: var(--radius-lg);
padding: 8px 12px;
color: var(--color-text-primary);
font-size: 0.95rem;
transition: border-color 0.2s;
}
.emote-search-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--shadow-accent-sm);
}
.emote-search-input::placeholder {
color: var(--color-text-muted);
}
.emote-menu-tabs {
display: flex;
gap: 0;
padding: 0 20px;
border-bottom: 2px solid var(--color-border-medium);
background: var(--color-bg-secondary);
height: 50px;
align-items: center;
}
.emote-tab {
flex: 1;
min-width: 80px;
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: var(--color-text-secondary);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.emote-tab:hover:not(.active) {
color: var(--color-text-primary);
opacity: 0.8;
}
.emote-tab.active {
border-bottom-color: var(--color-accent);
color: var(--color-accent);
}
.emote-menu-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.emote-source-section {
margin-bottom: 24px;
}
.emote-source-section:last-child {
margin-bottom: 0;
}
.emote-source-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid var(--color-border-medium);
}
.emote-source-dot {
width: 12px;
height: 12px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.emote-source-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.emote-source-count {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-left: auto;
}
.emote-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
}
.emote-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px;
border: 1px solid var(--color-border-medium);
border-radius: var(--radius-xl);
cursor: pointer;
transition: all 0.2s;
background: var(--color-bg-secondary);
overflow: hidden;
}
.emote-item {
position: relative;
}
.emote-disable-btn {
position: absolute;
top: 6px;
right: 6px;
background: rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.06);
color: var(--color-text-secondary);
padding: 4px 6px;
font-size: 0.7rem;
border-radius: 6px;
cursor: pointer;
}
.emote-disable-btn:hover {
background: rgba(0,0,0,0.5);
}
.emote-disabled {
opacity: 0.45;
filter: grayscale(70%);
}
.emote-item:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent);
transform: translateY(-2px);
box-shadow: var(--shadow-accent-md);
}
.emote-image {
width: 48px;
height: 48px;
object-fit: contain;
object-position: center;
}
.emote-name {
font-size: 0.75rem;
color: var(--color-text-secondary);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.emote-no-results {
text-align: center;
padding: 40px 20px;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.emote-no-results-section {
text-align: center;
padding: 20px;
color: var(--color-text-muted);
font-size: 0.85rem;
}
/* Scrollbar styling */
.emote-menu-content::-webkit-scrollbar {
width: 8px;
}
.emote-menu-content::-webkit-scrollbar-track {
background: transparent;
}
.emote-menu-content::-webkit-scrollbar-thumb {
background: var(--color-border-medium);
border-radius: var(--radius-md);
}
.emote-menu-content::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
/* Mobile responsive */
@media (max-width: 600px) {
.emote-menu-modal {
width: 95%;
max-height: 90vh;
}
.emote-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
}
.emote-image {
width: 40px;
height: 40px;
}
}

View File

@@ -0,0 +1,146 @@
.live-notification {
position: fixed;
bottom: 20px;
right: 20px;
background: #0a0a0a;
border: 2px solid #6366f1;
border-radius: 12px;
padding: 20px;
max-width: 320px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.2);
z-index: 1000;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.live-notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 10px;
}
.live-notification-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.live-badge {
background: #6366f1;
color: white;
font-weight: bold;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.streamer-name {
color: #e0e0e0;
font-weight: 600;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-notification-close {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 18px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
flex-shrink: 0;
}
.live-notification-close:hover {
color: #e0e0e0;
}
.streamer-avatar {
width: 100%;
max-width: 300px;
height: auto;
border-radius: 8px;
margin-bottom: 12px;
display: block;
}
.stream-title {
color: #b0b0b0;
font-size: 14px;
margin-bottom: 8px;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.viewer-count {
color: #a0a0a0;
font-size: 13px;
margin-bottom: 12px;
}
.view-now-button {
width: 100%;
background: #6366f1;
color: white;
border: none;
padding: 12px;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.view-now-button:hover {
background: #4f46e5;
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
.view-now-button:active {
transform: translateY(0);
}
@media (max-width: 480px) {
.live-notification {
bottom: 10px;
right: 10px;
left: 10px;
max-width: none;
}
}

View File

@@ -0,0 +1,39 @@
@import './vars.css';
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(10, 10, 10, 0.85);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
color: white;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid var(--color-border-medium);
border-top-color: var(--color-accent);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.loading-text {
font-size: 1.2rem;
color: var(--color-text-primary);
font-weight: 500;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

387
src/styles/LoginPanel.css Normal file
View File

@@ -0,0 +1,387 @@
@import './vars.css';
.login-panel {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
background: var(--color-bg-secondary);
box-shadow: none;
height: 100%;
overflow: hidden;
}
.login-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--color-border-medium);
}
.login-panel h3 {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
flex-shrink: 0;
}
.icon-button {
background: none;
border: 1px solid #333333;
color: #6366f1;
cursor: pointer;
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s;
border-radius: 4px;
flex-shrink: 0;
}
.icon-button:hover {
color: #4f46e5;
border-color: #6366f1;
}
.icon-button.youtube-icon-btn {
color: #FF0000;
}
.icon-button.youtube-icon-btn:hover {
color: #cc0000;
border-color: #FF0000;
}
.icon-button.small {
font-size: 14px;
}
.platform-login {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: none;
border-bottom: 1px solid var(--color-border-medium);
background: var(--color-bg-primary);
margin: 0;
flex: 1;
min-height: 0;
}
.platform-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.platform-name {
font-weight: 600;
font-size: 1rem;
}
.platform-login.twitch .platform-name {
color: var(--color-twitch);
}
.platform-login.youtube .platform-name {
color: var(--color-youtube);
}
.connected-info {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
flex: 1;
min-height: 0;
overflow: visible;
}
.user-profile-container {
display: flex;
align-items: center;
gap: 8px;
}
.user-profile-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 4px;
border-radius: 6px;
transition: background-color 0.2s;
min-width: 0;
}
.user-profile-link:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.profile-image {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--color-border-medium);
transition: transform 0.2s;
}
.profile-image:hover {
transform: scale(1.05);
}
.user-details {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
overflow: hidden;
}
.user-name {
margin: 0;
font-size: 0.8rem;
color: #e2e8f0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-actions {
display: flex;
gap: 4px;
}
.channels-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #333;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 8px;
display: block;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.channel-search-input {
width: 100%;
padding: 8px;
margin-bottom: 8px;
border: 1px solid #333333;
border-radius: 4px;
font-size: 0.9rem;
background-color: #0a0a0a;
color: #e2e8f0;
box-sizing: border-box;
flex-shrink: 0;
}
.channel-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: calc(100vh - 475px);
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.channel-item {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 8px 10px;
border-radius: 6px;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.channel-item.selected {
border-color: var(--color-accent);
background-color: rgba(158, 64, 115, 0.1);
}
.channel-item-image {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #333333;
flex-shrink: 0;
}
.channel-item-name {
font-size: 0.9rem;
color: #e2e8f0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewer-count {
font-size: 0.8rem;
color: #94a3b8;
flex-shrink: 0;
margin-left: 4px;
display: flex;
align-items: center;
gap: 2px;
}
.favorite-button {
background: none;
border: none;
cursor: pointer;
padding: 0 4px;
flex-shrink: 0;
color: #666666;
transition: color 0.2s ease;
line-height: 1;
display: flex;
align-items: center;
}
.favorite-button.active {
color: #fbbf24;
}
.manual-join-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #333;
flex-shrink: 0;
}
.join-form {
display: flex;
gap: 4px;
}
.join-input {
flex: 1;
padding: 8px;
border: 1px solid #333333;
font-size: 0.9rem;
background-color: #0a0a0a;
color: #e2e8f0;
min-width: 0;
}
.login-btn,
.logout-btn {
padding: 8px 12px;
border: none;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.login-btn:hover {
transform: translateY(-2px);
opacity: 0.9;
}
.login-btn:active {
transform: translateY(0);
}
.login-btn.twitch-btn {
background-color: #9146ff;
color: white;
}
.login-btn.youtube-btn {
background-color: #ff0000;
color: white;
}
.logout-btn {
background-color: #333333;
color: #e2e8f0;
flex-shrink: 0;
}
.logout-btn:hover {
background-color: #555555;
}
.logout-btn.small {
font-size: 12px;
padding: 4px 8px;
}
.youtube-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #333;
}
.youtube-info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.youtube-user-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.youtube-user-name {
font-size: 13px;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Channel list scrollbar styling */
.channel-list::-webkit-scrollbar {
width: 8px;
}
.channel-list::-webkit-scrollbar-track {
background: #0a0a0a;
}
.channel-list::-webkit-scrollbar-thumb {
background: #333333;
border-radius: var(--radius-md);
}
.channel-list::-webkit-scrollbar-thumb:hover {
background: #555555;
}

289
src/styles/MessageInput.css Normal file
View File

@@ -0,0 +1,289 @@
@import './vars.css';
.message-input {
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px;
background: var(--color-bg-primary);
border-top: 1px solid var(--color-border-dark);
}
.reply-indicator {
order: -1;
margin-bottom: 8px;
}
.reply-indicator > div:first-child {
order: 1;
margin-right: auto;
}
.platform-selector {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.platform-checkbox {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.platform-checkbox input {
cursor: pointer;
width: 18px;
height: 18px;
}
.platform-checkbox input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.platform-label {
font-size: 0.95rem;
font-weight: 500;
padding: 4px 8px;
transition: background-color 0.2s;
}
.platform-label.twitch {
color: var(--color-twitch);
}
.platform-label.twitch:not(.disabled):hover {
background-color: rgba(145, 70, 255, 0.1);
}
.platform-label.youtube {
color: var(--color-youtube);
}
.platform-label.youtube:not(.disabled):hover {
background-color: rgba(255, 0, 0, 0.1);
}
.platform-label.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-group {
display: flex;
gap: 8px;
align-items: stretch;
height: 36px;
}
.message-textarea {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--color-border-medium);
font-family: inherit;
font-size: 0.95rem;
resize: none;
height: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
overflow: hidden;
}
.message-textarea:focus {
outline: none;
border-color: var(--color-border-lighter);
box-shadow: 0 0 0 3px rgba(85, 85, 85, 0.2);
}
.message-textarea.twitch {
border-color: #9146ff;
}
.message-textarea.twitch:focus {
border-color: #a970ff;
box-shadow: 0 0 0 3px rgba(145, 70, 255, 0.2);
}
.message-textarea.youtube {
border-color: #ff0000;
}
.message-textarea.youtube:focus {
border-color: #ff3333;
box-shadow: 0 0 0 3px rgba(255, 0, 0, 0.2);
}
.message-textarea:disabled {
background-color: #1a1a1a;
cursor: not-allowed;
opacity: 0.5;
}
.send-button {
padding: 8px 12px;
background: #333333;
color: white;
border: none;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
height: 100%;
min-width: 36px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.send-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(85, 85, 85, 0.4);
}
.send-button:active:not(:disabled) {
transform: translateY(0);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.emote-picker-button {
padding: 0;
width: 36px;
min-width: 36px;
height: 100%;
background: #1a1a1a;
color: #cbd5e1;
border: 1px solid #333333;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.emote-picker-button:hover:not(:disabled) {
background: #262626;
border-color: #475569;
color: #e2e8f0;
transform: scale(1.05);
}
.emote-picker-button:active:not(:disabled) {
transform: scale(0.95);
}
.emote-picker-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.warning {
padding: 8px 12px;
background-color: #3d2817;
border-left: 4px solid #f59e0b;
color: #fde047;
font-size: 0.9rem;
}
/* Responsive design for lower resolutions */
@media (max-width: 768px) {
.message-input {
gap: 8px;
padding: 12px;
}
.platform-selector {
gap: 8px;
}
.platform-checkbox input {
width: 16px;
height: 16px;
}
.platform-label {
font-size: 0.9rem;
padding: 3px 6px;
}
.input-group {
height: 40px;
gap: 6px;
}
.message-textarea {
padding: 8px;
font-size: 0.9rem;
}
.send-button {
padding: 8px 16px;
font-size: 0.9rem;
min-width: 80px;
}
}
@media (max-width: 480px) {
.message-input {
gap: 6px;
padding: 8px;
}
.platform-selector {
gap: 6px;
flex-direction: column;
}
.platform-checkbox {
gap: 4px;
}
.platform-checkbox input {
width: 14px;
height: 14px;
}
.platform-label {
font-size: 0.85rem;
padding: 2px 4px;
}
.input-group {
height: 36px;
gap: 4px;
flex-direction: row;
}
.message-textarea {
padding: 6px;
font-size: 0.85rem;
height: 100%;
min-height: auto;
}
.emote-picker-button {
width: 36px;
min-width: 36px;
height: 100%;
}
.send-button {
padding: 6px 12px;
font-size: 0.85rem;
min-width: 60px;
height: 100%;
}
.warning {
padding: 6px 8px;
font-size: 0.8rem;
}
}

View File

@@ -0,0 +1,55 @@
@import './vars.css';
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: var(--radius-xl);
font-size: 14px;
box-shadow: var(--shadow-md);
animation: slideIn 0.3s ease-out, slideOut 0.3s ease-out 2.7s forwards;
max-width: 300px;
word-wrap: break-word;
z-index: 1000;
}
.notification-success {
background-color: var(--color-notification-success);
color: white;
border: 1px solid var(--color-notification-success-border);
}
.notification-error {
background-color: var(--color-notification-error);
color: white;
border: 1px solid var(--color-notification-error-border);
}
.notification-info {
background-color: var(--color-notification-info);
color: white;
border: 1px solid var(--color-notification-info-border);
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}

View File

@@ -0,0 +1,99 @@
@import './vars.css';
.unified-chat-wrapper {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.unified-messages-container {
flex: 1;
overflow-y: auto;
padding: 0 0 4px 0;
}
.platform-tag {
display: inline-flex;
align-items: center;
margin-right: 3px;
flex-shrink: 0;
}
.platform-tag.twitch {
color: #9147ff;
}
.platform-tag.youtube {
color: #FF0000;
}
/* Reusing some classes from ChatDisplay via global scope or if added here */
.twitch-event-icon {
margin-right: 4px;
display: inline-flex;
align-items: center;
}
.author.twitch-author:hover {
text-decoration: underline;
}
/* Ensure UnifiedChat specific overrides if needed */
/* Super Chat Styles - Reused from YouTubeChat.css */
.yt-superchat {
margin: 4px 8px;
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.yt-superchat-header {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
}
.yt-superchat-header .avatar {
width: 40px !important;
height: 40px !important;
border-radius: 50%;
}
.yt-superchat-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.yt-superchat-author {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
}
.yt-superchat-amount {
color: #fff;
font-size: 14px;
}
.yt-superchat-body {
padding: 8px 12px;
background-color: rgba(0, 0, 0, 0.1);
color: #fff;
word-break: break-word;
line-height: 1.4;
}
.yt-superchat-sticker {
width: 72px;
height: 72px;
margin-top: 4px;
object-fit: contain;
}

View File

@@ -0,0 +1,346 @@
.watchlist-manager-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.watchlist-manager {
background: var(--color-bg-primary);
border: 1px solid var(--color-accent);
border-radius: 12px;
padding: 28px;
max-width: 500px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-accent-lg);
color: var(--color-text-primary);
}
.watchlist-manager h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: var(--color-text-white);
}
.subtitle {
margin: 0 0 20px 0;
font-size: 13px;
color: var(--color-text-secondary);
}
.watchlist-add-section {
display: flex;
gap: 8px;
margin-bottom: 20px;
position: relative;
}
.channel-search {
flex: 1;
padding: 10px 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
color: var(--color-text-primary);
font-size: 13px;
cursor: text;
transition: border-color 0.2s;
}
.channel-search:hover,
.channel-search:focus {
border-color: var(--color-accent);
outline: none;
}
.channel-list-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 72px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
z-index: 10;
display: none;
}
.channel-search:focus ~ .channel-list-dropdown {
display: block;
}
.channel-list-dropdown:hover {
display: block;
}
.channel-search:focus ~ .channel-list-dropdown:hover {
display: block;
}
.no-channels-message {
padding: 16px;
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
}
.channel-list-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: none;
border: none;
border-bottom: 1px solid var(--color-border-dark);
color: var(--color-text-primary);
cursor: pointer;
transition: background 0.2s;
text-align: left;
font-size: 13px;
}
.channel-list-item:last-child {
border-bottom: none;
}
.channel-list-item:hover {
background: var(--color-bg-tertiary);
}
.channel-thumb {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.channel-info {
flex: 1;
min-width: 0;
}
.channel-display-name {
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-username {
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-channel-display {
padding: 10px 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
color: var(--color-text-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.add-button {
padding: 10px 16px;
background: var(--color-accent);
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
white-space: nowrap;
}
.add-button:hover:not(:disabled) {
background: var(--color-accent-hover);
transform: translateY(-1px);
}
.add-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.watchlist-items {
flex: 1;
overflow-y: auto;
margin-bottom: 16px;
padding-right: 8px;
}
.watchlist-items::-webkit-scrollbar {
width: 6px;
}
.watchlist-items::-webkit-scrollbar-track {
background: transparent;
}
.watchlist-items::-webkit-scrollbar-thumb {
background: var(--color-border-medium);
border-radius: 3px;
}
.watchlist-items::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
.empty-message {
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
padding: 20px;
margin: 0;
}
.watchlist-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
margin-bottom: 8px;
transition: all 0.2s;
}
.watchlist-item:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent);
}
.item-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.item-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.item-details {
min-width: 0;
flex: 1;
}
.item-name {
font-weight: 600;
font-size: 14px;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-username {
font-size: 12px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-button {
background: none;
border: none;
color: var(--color-accent);
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
font-size: 14px;
}
.remove-button:hover {
color: var(--color-accent-hover);
transform: scale(1.1);
}
.watchlist-footer {
border-top: 1px solid var(--color-border-medium);
padding-top: 16px;
}
.info-text {
display: flex;
align-items: center;
font-size: 12px;
color: var(--color-text-secondary);
margin: 0 0 12px 0;
}
.close-button {
width: 100%;
padding: 10px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.close-button:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent);
}
@media (max-width: 480px) {
.watchlist-manager {
padding: 20px;
max-width: 100%;
max-height: 90vh;
}
.watchlist-manager h2 {
font-size: 18px;
}
}

233
src/styles/YouTubeChat.css Normal file
View File

@@ -0,0 +1,233 @@
.yt-chat-outer {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-bg-primary);
color: var(--color-text-white);
}
.yt-chat-container {
font-size: var(--chat-font-size, 13px);
color: var(--color-text-white);
background: var(--color-bg-primary);
height: 100%;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Custom scrollbar styling - matching Twitch chat */
.yt-chat-container::-webkit-scrollbar {
width: 8px;
}
.yt-chat-container::-webkit-scrollbar-track {
background: var(--color-bg-primary);
}
.yt-chat-container::-webkit-scrollbar-thumb {
background: var(--color-border-medium);
border-radius: var(--radius-md);
}
.yt-chat-container::-webkit-scrollbar-thumb:hover {
background: var(--color-border-lighter);
}
.yt-chat-status {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #0a0a0a;
color: #999;
}
.yt-chat-error {
flex-direction: column;
padding: 20px;
text-align: center;
gap: 10px;
}
.yt-chat-error-sub {
font-size: 12px;
color: #666;
}
.yt-chat-placeholder {
color: #666;
text-align: center;
padding: 20px;
}
.yt-message-badges {
display: flex;
align-items: center;
margin-right: 4px;
gap: 2px;
}
.yt-badge {
width: 18px;
height: 18px;
vertical-align: middle;
}
.yt-emoji {
vertical-align: middle;
margin: 0 1px;
object-fit: contain;
}
.yt-emoji.custom {
width: 28px;
height: 28px;
}
.yt-emoji.standard {
width: 18px;
height: 18px;
}
.yt-message-input-wrapper {
padding: 8px 10px;
gap: 8px;
display: flex;
}
.yt-input-group {
position: relative;
flex: 1;
}
.emote-autocomplete-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
max-height: 260px;
overflow-y: auto;
background: #1e293b;
border: 1px solid #475569;
border-radius: 8px;
margin-bottom: 6px;
z-index: 1000;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
}
.emote-autocomplete-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
cursor: pointer;
transition: background 0.12s;
}
.emote-autocomplete-item.selected {
background: #334155;
}
.emote-autocomplete-item img {
object-fit: contain;
flex-shrink: 0;
}
.emote-autocomplete-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.emote-autocomplete-name {
color: #e2e8f0;
font-weight: 500;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emote-autocomplete-type {
color: #94a3b8;
font-size: 11px;
}
.yt-status-refreshing {
padding: 6px 12px;
background-color: #0f0f0f;
border-top: 1px solid #333;
font-size: 11px;
color: #888;
text-align: center;
}
.yt-status-login-required {
padding: 6px 12px;
background-color: #0f0f0f;
border-top: 1px solid #333;
font-size: 11px;
color: #666;
text-align: center;
}
/* Super Chat Styles */
.yt-superchat {
margin: 4px 0;
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.yt-superchat-header {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
}
.yt-superchat-header .avatar {
width: 40px !important;
height: 40px !important;
border-radius: 50%;
}
.yt-superchat-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.yt-superchat-author {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
}
.yt-superchat-amount {
color: #fff;
font-size: 14px;
}
.yt-superchat-body {
padding: 8px 12px;
background-color: rgba(0, 0, 0, 0.1);
color: #fff;
word-break: break-word;
line-height: 1.4;
}
.yt-superchat-sticker {
width: 72px;
height: 72px;
margin-top: 4px;
object-fit: contain;
}

View File

@@ -0,0 +1,363 @@
/* YouTubeLinker modal mirrors WatchlistManager styles */
.ytlinker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
animation: fadeIn 0.2s ease-out;
}
.ytlinker-manager {
background: var(--color-bg-primary);
border: 1px solid var(--color-accent);
border-radius: 12px;
padding: 28px;
max-width: 540px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-accent-lg);
color: var(--color-text-primary);
}
.ytlinker-manager h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
color: var(--color-text-white);
display: flex;
align-items: center;
}
.ytlinker-subtitle {
margin: 0 0 20px 0;
font-size: 13px;
color: var(--color-text-secondary);
}
/* Add section */
.ytlinker-add-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.ytlinker-inputs {
display: flex;
flex-direction: column;
gap: 12px;
}
.ytlinker-channel-select,
.ytlinker-url-input {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
}
.ytlinker-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ytlinker-search {
width: 100%;
padding: 10px 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
color: var(--color-text-primary);
font-size: 13px;
cursor: text;
transition: border-color 0.2s;
box-sizing: border-box;
}
.ytlinker-search:hover,
.ytlinker-search:focus {
border-color: var(--color-accent);
outline: none;
}
/* Dropdown for channel list */
.ytlinker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 200px;
overflow-y: auto;
z-index: 10;
display: none;
}
.ytlinker-search:focus ~ .ytlinker-dropdown {
display: block;
}
.ytlinker-dropdown:hover {
display: block;
}
.ytlinker-search:focus ~ .ytlinker-dropdown:hover {
display: block;
}
.ytlinker-no-channels {
padding: 16px;
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
}
.ytlinker-channel-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: none;
border: none;
border-bottom: 1px solid var(--color-border-dark);
color: var(--color-text-primary);
cursor: pointer;
transition: background 0.2s;
text-align: left;
font-size: 13px;
}
.ytlinker-channel-item:last-child {
border-bottom: none;
}
.ytlinker-channel-item:hover {
background: var(--color-bg-tertiary);
}
.ytlinker-channel-info {
flex: 1;
min-width: 0;
}
.ytlinker-channel-display-name {
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ytlinker-channel-username {
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ytlinker-selected-display {
padding: 6px 10px;
background: rgba(99, 102, 241, 0.1);
border: 1px solid var(--color-accent);
border-radius: 6px;
color: var(--color-accent);
font-size: 12px;
font-weight: 600;
}
.ytlinker-error {
color: #ef4444;
font-size: 12px;
padding: 4px 0;
}
.ytlinker-add-button {
padding: 10px 16px;
background: #FF0000;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
white-space: nowrap;
}
.ytlinker-add-button:hover:not(:disabled) {
background: #cc0000;
transform: translateY(-1px);
}
.ytlinker-add-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Linked items list */
.ytlinker-items {
flex: 1;
overflow-y: auto;
margin-bottom: 16px;
padding-right: 8px;
}
.ytlinker-items::-webkit-scrollbar {
width: 6px;
}
.ytlinker-items::-webkit-scrollbar-track {
background: transparent;
}
.ytlinker-items::-webkit-scrollbar-thumb {
background: var(--color-border-medium);
border-radius: 3px;
}
.ytlinker-items::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
.ytlinker-empty {
text-align: center;
color: var(--color-text-muted);
font-size: 13px;
padding: 20px;
margin: 0;
}
.ytlinker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
margin-bottom: 8px;
transition: all 0.2s;
}
.ytlinker-item:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent);
}
.ytlinker-item-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.ytlinker-item-details {
min-width: 0;
flex: 1;
}
.ytlinker-item-name {
font-weight: 600;
font-size: 14px;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ytlinker-item-url {
font-size: 12px;
color: #FFFFFF;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
display: block;
}
.ytlinker-item-url:hover {
text-decoration: underline;
}
.ytlinker-remove-button {
background: none;
border: none;
color: #FF0000;
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
font-size: 14px;
}
.ytlinker-remove-button:hover {
color: #cc0000;
transform: scale(1.1);
}
/* Footer */
.ytlinker-footer {
border-top: 1px solid var(--color-border-medium);
padding-top: 16px;
}
.ytlinker-info-text {
display: flex;
align-items: center;
font-size: 12px;
color: var(--color-text-secondary);
margin: 0 0 12px 0;
}
.ytlinker-close-button {
width: 100%;
padding: 10px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.ytlinker-close-button:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-accent);
}
@media (max-width: 480px) {
.ytlinker-manager {
padding: 20px;
max-width: 100%;
max-height: 90vh;
}
.ytlinker-manager h2 {
font-size: 18px;
}
}

36
src/styles/global.css Normal file
View File

@@ -0,0 +1,36 @@
@import './vars.css';
* {
box-sizing: border-box;
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
background: var(--color-bg-primary);
min-height: 100vh;
color: var(--color-text-primary);
}
#app {
min-height: 100vh;
}
/* Responsive layout - stack vertically on smaller screens */
@media (max-width: 1024px) {
[data-app-grid] {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
[data-stream-embed] {
max-height: 50vh;
}
}

59
src/styles/vars.css Normal file
View File

@@ -0,0 +1,59 @@
/* Normalized Color Palette */
:root {
/* Primary Background Colors */
--color-bg-primary: #0a0a0a;
--color-bg-secondary: #1a1a1a;
--color-bg-tertiary: #2a2a2a;
--color-bg-hover: #3a3a3a;
/* Border & Divider Colors */
--color-border-dark: #1a1a1a;
--color-border-medium: #333333;
--color-border-light: #444444;
--color-border-lighter: #555555;
/* Text Colors */
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-white: #ffffff;
/* Platform Colors */
--color-twitch: #9146ff;
--color-youtube: #ff0000;
/* Accent Color */
--color-accent: #9e4073;
--color-accent-hover: #5e2645;
/* Status Colors */
--color-status-success: #d1fae5;
--color-status-success-text: #065f46;
--color-status-error: #fee2e2;
--color-status-error-text: #7f1d1d;
/* Notification Colors */
--color-notification-success: #10b981;
--color-notification-success-border: #059669;
--color-notification-error: #ef4444;
--color-notification-error-border: #dc2626;
--color-notification-info: #3b82f6;
--color-notification-info-border: #2563eb;
/* Shadow */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
--shadow-sm-dark: 2px 0 10px rgba(0, 0, 0, 0.5);
--shadow-accent-sm: 0 0 0 3px rgba(99, 102, 241, 0.1);
--shadow-accent-md: 0 4px 12px rgba(99, 102, 241, 0.2);
--shadow-accent-lg: 0 10px 40px rgba(99, 102, 241, 0.15);
/* Border Radius */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--radius-xl: 8px;
--radius-2xl: 12px;
--radius-full: 50%;
}

6
src/types/twitch-m3u8.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "twitch-m3u8" {
export function getStream(channel: string, raw?: boolean): Promise<string>;
export default {
getStream,
};
}