Watch another stream while chilling in an offline chat
// ==UserScript==
// @name Twitch Offchat Stream Viewer
// @namespace http://tampermonkey.net/
// @version 1.4
// @description Watch another stream while chilling in an offline chat
// @author benno2503
// @match https://www.twitch.tv/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const style = document.createElement('style');
style.textContent = `
#offchat-embed-wrapper {
box-sizing: border-box !important;
}
#offchat-embed-wrapper iframe,
#offchat-twitch-iframe {
box-sizing: border-box !important;
border: none !important;
}
`;
document.head.appendChild(style);
const IGNORED_PATHS = [
'directory', 'following', 'settings', 'subscriptions',
'inventory', 'wallet', 'drops', 'videos', 'p', 'search',
'downloads', 'turbo', 'jobs', 'about', 'prime', 'bits'
];
let config = { lastStreams: GM_getValue('lastStreams', []) };
let currentChannel = '';
let controlPanel = null;
let chatControlPanel = null;
let openChats = []; // Array of { channel, window, minimizeButton, isMinimized, chatMode, settingsIcon, overlays, resizeHandle }
let openStreams = []; // Array of { id, type: 'twitch'|'youtube', displayName }
let focusedStreamIndex = null; // Index of focused stream in spotlight mode
let streamContainer = null; // Container for all streams
let currentPanelMode = 'stream'; // 'stream' or 'chat'
let lastChannel = null;
let buttonCheckInterval = null;
let streamWrapperCache = {}; // Cache stream wrappers (including iframes) to prevent reloading
let resizeUpdateBound = null; // Bound resize update function to prevent duplicates
let isTheaterMode = false; // Theater mode state
let theaterModeStyle = null; // Style element for theater mode
let isMuted = false; // Global mute state for sync mute
let isMainStreamHidden = false; // Hide main Twitch stream state
function isChannelPage() {
const path = window.location.pathname.split('/')[1]?.toLowerCase();
if (!path) return false;
if (IGNORED_PATHS.includes(path)) return false;
return /^[a-z0-9_]+$/.test(path);
}
function getCurrentChannel() {
const path = window.location.pathname.split('/')[1];
if (path && isChannelPage()) return path.toLowerCase();
return null;
}
function extractChannelName(input) {
if (!input) return null;
input = input.trim();
const urlMatch = input.match(/twitch\.tv\/([a-zA-Z0-9_]+)/);
if (urlMatch) return urlMatch[1].toLowerCase();
return input.toLowerCase().replace(/[^a-z0-9_]/g, '');
}
function extractYouTubeVideoId(input) {
if (!input) return null;
input = input.trim();
// Match various YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
/youtube\.com\/v\/([a-zA-Z0-9_-]{11})/
];
for (const pattern of patterns) {
const match = input.match(pattern);
if (match) return match[1];
}
// If it looks like a video ID (11 chars), return it
if (/^[a-zA-Z0-9_-]{11}$/.test(input)) {
return input;
}
return null;
}
function isYouTubeUrl(input) {
return input && (input.includes('youtube.com') || input.includes('youtu.be'));
}
function makeDraggable(element, handle) {
let isDragging = false;
let startX, startY, startLeft, startTop;
handle.style.cursor = 'grab';
handle.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
isDragging = true;
handle.style.cursor = 'grabbing';
startX = e.clientX;
startY = e.clientY;
const rect = element.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
element.style.left = (startLeft + dx) + 'px';
element.style.top = (startTop + dy) + 'px';
element.style.right = 'auto';
element.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
isDragging = false;
handle.style.cursor = 'grab';
});
}
async function createEmbedPlayer(input) {
if (!input) return;
let streamData;
// Check if it's a YouTube URL
if (isYouTubeUrl(input)) {
const videoId = extractYouTubeVideoId(input);
if (!videoId) {
alert("Invalid YouTube URL!");
return;
}
// Check if already open
if (openStreams.some(s => s.id === videoId && s.type === 'youtube')) {
alert("This YouTube video is already open!");
return;
}
// Fetch video title
let displayName = `YT: ${videoId.substring(0, 8)}...`;
try {
const response = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
if (response.ok) {
const data = await response.json();
displayName = data.title || displayName;
}
} catch (e) {
console.log('Could not fetch YouTube title:', e);
}
streamData = {
id: videoId,
type: 'youtube',
displayName: displayName
};
} else {
// Twitch channel
const channel = extractChannelName(input);
if (!channel) return;
// Check if already open
if (openStreams.some(s => s.id === channel && s.type === 'twitch')) {
alert("This stream is already open!");
return;
}
streamData = {
id: channel,
type: 'twitch',
displayName: channel
};
}
// Limit to 4 streams
if (openStreams.length >= 4) {
alert("Maximum 4 streams allowed!");
return;
}
// Save to recent (only Twitch channels)
if (streamData.type === 'twitch') {
config.lastStreams = config.lastStreams.filter(s => s !== streamData.id);
config.lastStreams.unshift(streamData.id);
if (config.lastStreams.length > 10) config.lastStreams.pop();
GM_setValue('lastStreams', config.lastStreams);
}
openStreams.push(streamData);
renderStreamContainer();
updateControlPanel();
// Apply current mute state to newly added stream
if (isMuted) {
setTimeout(() => {
const streamKey = `${streamData.type}-${streamData.id}`;
const wrapper = streamWrapperCache[streamKey];
if (wrapper) {
const iframe = wrapper.querySelector('iframe');
if (iframe && iframe.contentWindow) {
if (streamData.type === 'twitch') {
try {
iframe.contentWindow.postMessage(
JSON.stringify({
namespace: 'twitch-embed-player-proxy',
method: 'setMuted',
arguments: [true]
}),
'https://player.twitch.tv'
);
} catch (e) {
console.log('[Offchat] Failed to mute new Twitch stream:', e);
}
} else if (streamData.type === 'youtube') {
try {
iframe.contentWindow.postMessage(
JSON.stringify({
event: 'command',
func: 'mute',
args: []
}),
'https://www.youtube.com'
);
} catch (e) {
console.log('[Offchat] Failed to mute new YouTube stream:', e);
}
}
}
}
}, 1000); // Wait 1 second for iframe to load
}
}
function removeStream(streamId, streamType) {
const index = openStreams.findIndex(s => s.id === streamId && s.type === streamType);
if (index === -1) return;
// Remove from cache
const streamKey = `${streamType}-${streamId}`;
const wrapper = streamWrapperCache[streamKey];
if (wrapper && wrapper.parentNode) {
wrapper.remove();
}
delete streamWrapperCache[streamKey];
openStreams.splice(index, 1);
// Reset focus if we removed the focused stream
if (focusedStreamIndex === index) {
focusedStreamIndex = null;
} else if (focusedStreamIndex !== null && focusedStreamIndex > index) {
focusedStreamIndex--;
}
if (openStreams.length === 0) {
removeStreamContainer();
} else {
renderStreamContainer();
}
updateControlPanel();
}
function removeStreamContainer() {
if (streamContainer) {
streamContainer.remove();
streamContainer = null;
}
if (resizeUpdateBound) {
window.removeEventListener('resize', resizeUpdateBound);
window.removeEventListener('scroll', resizeUpdateBound);
resizeUpdateBound = null;
}
openStreams = [];
focusedStreamIndex = null;
streamWrapperCache = {}; // Clear wrapper cache
}
function toggleStreamFocus(index) {
if (focusedStreamIndex === index) {
focusedStreamIndex = null; // Return to grid
} else {
focusedStreamIndex = index; // Spotlight this stream
}
renderStreamContainer();
}
function renderStreamContainer() {
if (openStreams.length === 0) {
removeStreamContainer();
return;
}
const numStreams = openStreams.length;
const containerPadding = numStreams === 1 ? '0px' : '8px';
let containerRect;
let playerContainer = null;
// In theater mode, use full viewport
if (isTheaterMode) {
containerRect = {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight
};
} else {
// Normal mode: position over player container
playerContainer = document.querySelector('[data-a-target="video-player"]') ||
document.querySelector('.video-player__container') ||
document.querySelector('.video-player') ||
document.querySelector('.persistent-player');
if (playerContainer) {
containerRect = playerContainer.getBoundingClientRect();
} else {
const chatWidth = 340;
const navHeight = 50;
containerRect = {
top: navHeight,
left: 0,
width: window.innerWidth - chatWidth,
height: window.innerHeight - navHeight
};
}
}
// Check if container exists
let existing = document.getElementById('offchat-embed-wrapper');
if (!existing) {
// Create new container only if it doesn't exist
streamContainer = document.createElement('div');
streamContainer.id = 'offchat-embed-wrapper';
// Always use containerRect for positioning (works in both modes)
streamContainer.style.cssText = `
position: fixed !important;
top: ${containerRect.top}px !important;
left: ${containerRect.left}px !important;
width: ${containerRect.width}px !important;
height: ${containerRect.height}px !important;
z-index: 1000 !important;
background: #0e0e10 !important;
overflow: hidden !important;
box-sizing: border-box !important;
display: block !important;
padding: ${containerPadding} !important;
gap: 8px !important;
`;
document.body.appendChild(streamContainer);
} else {
// Update existing container styling
streamContainer = existing;
streamContainer.style.padding = containerPadding;
// Always update position (theater mode or normal)
streamContainer.style.top = containerRect.top + 'px';
streamContainer.style.left = containerRect.left + 'px';
streamContainer.style.width = containerRect.width + 'px';
streamContainer.style.height = containerRect.height + 'px';
}
// Calculate layout based on number of streams and focus mode
const isSpotlight = focusedStreamIndex !== null;
// Track which stream keys are currently active
const activeStreamKeys = new Set();
openStreams.forEach((stream, index) => {
const streamKey = `${stream.type}-${stream.id}`;
activeStreamKeys.add(streamKey);
// Check if wrapper already exists in cache
let streamWrapper = streamWrapperCache[streamKey];
if (!streamWrapper) {
// Create new wrapper and iframe
streamWrapper = document.createElement('div');
streamWrapper.className = 'offchat-stream-wrapper';
streamWrapper.dataset.streamKey = streamKey;
const iframe = document.createElement('iframe');
// Set iframe source based on stream type
if (stream.type === 'youtube') {
iframe.src = `https://www.youtube.com/embed/${stream.id}?autoplay=1&mute=0&rel=0&enablejsapi=1&origin=${encodeURIComponent(window.location.origin)}`;
} else {
iframe.src = `https://player.twitch.tv/?channel=${stream.id}&parent=www.twitch.tv&muted=false&autoplay=true`;
}
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('allow', 'autoplay; fullscreen');
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
streamWrapper.appendChild(iframe);
streamWrapperCache[streamKey] = streamWrapper;
// Append to container immediately after creation
streamContainer.appendChild(streamWrapper);
}
// Update wrapper position and styling using individual properties
const styleProps = getStreamWrapperStyleProps(index, numStreams, isSpotlight, containerRect);
streamWrapper.style.position = styleProps.position;
streamWrapper.style.top = styleProps.top;
streamWrapper.style.left = styleProps.left;
streamWrapper.style.width = styleProps.width;
streamWrapper.style.height = styleProps.height;
streamWrapper.style.border = styleProps.border;
streamWrapper.style.borderRadius = styleProps.borderRadius;
streamWrapper.style.boxSizing = styleProps.boxSizing;
streamWrapper.style.overflow = styleProps.overflow;
streamWrapper.style.transition = styleProps.transition;
streamWrapper.style.zIndex = styleProps.zIndex || '1';
streamWrapper.style.cursor = 'pointer';
streamWrapper.onclick = () => toggleStreamFocus(index);
// Update iframe border radius only
const iframe = streamWrapper.querySelector('iframe');
if (iframe) {
iframe.style.borderRadius = numStreams === 1 ? '0' : '8px';
}
});
// Remove any wrappers that are no longer needed
Object.keys(streamWrapperCache).forEach(key => {
if (!activeStreamKeys.has(key)) {
const wrapper = streamWrapperCache[key];
if (wrapper.parentNode) {
wrapper.remove();
}
delete streamWrapperCache[key];
}
});
// Update on resize - only add listeners once
if (!resizeUpdateBound) {
resizeUpdateBound = () => {
if (!streamContainer) return;
let rect;
// In theater mode, use full viewport
if (isTheaterMode) {
rect = {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight
};
} else {
// Normal mode: use player container
const playerContainer = document.querySelector('[data-a-target="video-player"]') ||
document.querySelector('.video-player__container') ||
document.querySelector('.video-player') ||
document.querySelector('.persistent-player');
if (playerContainer) {
rect = playerContainer.getBoundingClientRect();
} else {
return; // No player container found
}
}
streamContainer.style.top = rect.top + 'px';
streamContainer.style.left = rect.left + 'px';
streamContainer.style.width = rect.width + 'px';
streamContainer.style.height = rect.height + 'px';
const numStreams = openStreams.length;
const isSpotlight = focusedStreamIndex !== null;
// Re-calculate stream positions
openStreams.forEach((stream, index) => {
const streamKey = `${stream.type}-${stream.id}`;
const wrapper = streamWrapperCache[streamKey];
if (wrapper) {
const styleProps = getStreamWrapperStyleProps(index, numStreams, isSpotlight, rect);
wrapper.style.position = styleProps.position;
wrapper.style.top = styleProps.top;
wrapper.style.left = styleProps.left;
wrapper.style.width = styleProps.width;
wrapper.style.height = styleProps.height;
wrapper.style.border = styleProps.border;
wrapper.style.borderRadius = styleProps.borderRadius;
wrapper.onclick = () => toggleStreamFocus(index);
wrapper.style.cursor = 'pointer';
}
});
};
window.addEventListener('resize', resizeUpdateBound);
window.addEventListener('scroll', resizeUpdateBound);
}
}
function getStreamWrapperStyleProps(index, numStreams, isSpotlight, containerRect) {
const padding = 8;
const gap = 8;
const borderWidth = 3;
const availableWidth = containerRect.width - (padding * 2);
const availableHeight = containerRect.height - (padding * 2);
if (isSpotlight) {
// Spotlight mode: one large, others as thumbnails in corner
if (index === focusedStreamIndex) {
// Large focused stream
return {
position: 'absolute',
top: padding + 'px',
left: padding + 'px',
width: availableWidth + 'px',
height: availableHeight + 'px',
border: borderWidth + 'px solid #9147ff',
borderRadius: '12px',
boxSizing: 'border-box',
overflow: 'hidden',
transition: 'all 0.3s ease'
};
} else {
// Small thumbnail streams
const thumbnailWidth = 200;
const thumbnailHeight = 113; // 16:9 ratio
const thumbnailIndex = index > focusedStreamIndex ? index - 1 : index;
const thumbnailX = padding + gap;
const thumbnailY = padding + gap + (thumbnailIndex * (thumbnailHeight + gap));
return {
position: 'absolute',
top: thumbnailY + 'px',
left: thumbnailX + 'px',
width: thumbnailWidth + 'px',
height: thumbnailHeight + 'px',
border: borderWidth + 'px solid #bf94ff',
borderRadius: '8px',
boxSizing: 'border-box',
overflow: 'hidden',
transition: 'all 0.3s ease',
zIndex: '10'
};
}
} else {
// Grid mode
if (numStreams === 1) {
// Single stream - no border, fill completely
return {
position: 'absolute',
top: '0',
left: '0',
width: containerRect.width + 'px',
height: containerRect.height + 'px',
border: 'none',
borderRadius: '0',
boxSizing: 'border-box',
overflow: 'hidden',
transition: 'all 0.3s ease'
};
} else if (numStreams === 2) {
// Top/bottom layout
const streamHeight = (availableHeight - gap) / 2;
const top = index === 0 ? padding : padding + streamHeight + gap;
return {
position: 'absolute',
top: top + 'px',
left: padding + 'px',
width: availableWidth + 'px',
height: streamHeight + 'px',
border: borderWidth + 'px solid #9147ff',
borderRadius: '12px',
boxSizing: 'border-box',
overflow: 'hidden',
transition: 'all 0.3s ease'
};
} else {
// 2x2 grid for 3-4 streams
const streamWidth = (availableWidth - gap) / 2;
const streamHeight = (availableHeight - gap) / 2;
const col = index % 2;
const row = Math.floor(index / 2);
const left = padding + (col * (streamWidth + gap));
const top = padding + (row * (streamHeight + gap));
return {
position: 'absolute',
top: top + 'px',
left: left + 'px',
width: streamWidth + 'px',
height: streamHeight + 'px',
border: borderWidth + 'px solid #9147ff',
borderRadius: '12px',
boxSizing: 'border-box',
overflow: 'hidden',
transition: 'all 0.3s ease'
};
}
}
}
function removeEmbedPlayer() {
removeStreamContainer();
updateControlPanel();
}
function createChatWindow(channelInput) {
if (!channelInput) return;
const channel = extractChannelName(channelInput);
if (!channel) return;
// Check if chat for this channel is already open
const existingChat = openChats.find(c => c.channel === channel);
if (existingChat) {
// If minimized, show it. Otherwise, just focus it.
if (existingChat.isMinimized) {
showChatWindow(channel);
} else {
existingChat.window.style.zIndex = '10002';
}
return;
}
config.lastStreams = config.lastStreams.filter(s => s !== channel);
config.lastStreams.unshift(channel);
if (config.lastStreams.length > 10) config.lastStreams.pop();
GM_setValue('lastStreams', config.lastStreams);
// Calculate position offset based on number of open chats
const offset = openChats.length * 30;
const chatWindow = document.createElement('div');
chatWindow.id = `offchat-chat-window-${channel}`;
chatWindow.style.cssText = `
position: fixed;
top: ${100 + offset}px;
right: ${20 + offset}px;
width: 340px;
height: 500px;
background: #18181b;
border: 1px solid #2f2f35;
border-radius: 6px;
z-index: 10002;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
overflow: auto;
resize: both;
min-width: 250px;
min-height: 300px;
max-width: 90vw;
max-height: 90vh;
`;
const chatHeader = document.createElement('div');
chatHeader.id = 'offchat-chat-header';
chatHeader.style.cssText = `
padding: 10px 12px;
background: #1f1f23;
border-bottom: 1px solid #2f2f35;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
cursor: move;
`;
chatHeader.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" style="color: #bf94ff;">
<path d="M7.828 13L10 15.172 12.172 13H15a1 1 0 001-1V4a1 1 0 00-1-1H5a1 1 0 00-1 1v8a1 1 0 001 1h2.828zM10 18l-3-3H5a3 3 0 01-3-3V4a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3h-2l-3 3z"/>
</svg>
<span style="font-weight: 600; font-size: 13px; color: #dedee3;">${channel}</span>
</div>
<div style="display: flex; gap: 8px;">
<button class="offchat-chat-settings-btn" data-channel="${channel}" style="
background: none;
border: none;
color: #898395;
cursor: pointer;
font-size: 14px;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
" title="Minimal Mode">
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 8a2 2 0 100 4 2 2 0 000-4zM2 10a2 2 0 114 0 2 2 0 01-4 0zm12 0a2 2 0 114 0 2 2 0 01-4 0z"/>
</svg>
</button>
<button class="offchat-minimize-chat-btn" data-channel="${channel}" style="
background: none;
border: none;
color: #898395;
cursor: pointer;
font-size: 16px;
padding: 0;
line-height: 1;
" title="Minimize">−</button>
<button class="offchat-close-chat-btn" data-channel="${channel}" style="
background: none;
border: none;
color: #898395;
cursor: pointer;
font-size: 16px;
padding: 0;
line-height: 1;
" title="Close">×</button>
</div>
`;
const chatIframe = document.createElement('iframe');
chatIframe.id = 'offchat-chat-iframe';
chatIframe.src = `https://www.twitch.tv/embed/${channel}/chat?parent=www.twitch.tv&darkpopout`;
chatIframe.style.cssText = `
width: 100%;
border: none;
flex: 1 1 auto;
min-height: 0;
`;
chatWindow.appendChild(chatHeader);
chatWindow.appendChild(chatIframe);
document.body.appendChild(chatWindow);
makeDraggable(chatWindow, chatHeader);
const settingsBtn = chatWindow.querySelector('.offchat-chat-settings-btn');
const minimizeBtn = chatWindow.querySelector('.offchat-minimize-chat-btn');
const closeBtn = chatWindow.querySelector('.offchat-close-chat-btn');
if (settingsBtn) {
settingsBtn.onclick = () => toggleChatMinimalMode(channel);
}
if (minimizeBtn) {
minimizeBtn.onclick = () => minimizeChatWindow(channel);
}
if (closeBtn) {
closeBtn.onclick = () => removeChatWindow(channel);
}
// Add to openChats array
openChats.push({
channel: channel,
window: chatWindow,
minimizeButton: null,
isMinimized: false,
chatMode: 'normal', // 'normal', 'minimal', 'ultra'
settingsIcon: null,
overlays: null,
resizeHandle: null
});
updateControlPanel();
}
function removeChatWindow(channel) {
const chatIndex = openChats.findIndex(c => c.channel === channel);
if (chatIndex === -1) return;
const chat = openChats[chatIndex];
if (chat.window) chat.window.remove();
if (chat.minimizeButton) chat.minimizeButton.remove();
if (chat.settingsIcon) chat.settingsIcon.remove();
if (chat.resizeHandle) chat.resizeHandle.remove();
// No need to clean up overlays since iframe is removed with window
openChats.splice(chatIndex, 1);
updateControlPanel();
}
function minimizeChatWindow(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
chat.window.style.display = 'none';
chat.isMinimized = true;
createChatMinimizeButton(channel);
updateControlPanel();
}
function showChatWindow(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
chat.window.style.display = 'flex';
chat.window.style.zIndex = '10002';
chat.isMinimized = false;
if (chat.minimizeButton) {
chat.minimizeButton.remove();
chat.minimizeButton = null;
}
updateControlPanel();
}
function toggleChatMinimalMode(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
// Cycle through modes: normal → minimal → ultra → normal
if (chat.chatMode === 'normal') {
chat.chatMode = 'minimal';
} else if (chat.chatMode === 'minimal') {
chat.chatMode = 'ultra';
} else {
chat.chatMode = 'normal';
}
const chatHeader = chat.window.querySelector('#offchat-chat-header');
const chatIframe = chat.window.querySelector('#offchat-chat-iframe');
// Clean up any existing overlays, settings icon, and resize handle
if (chat.settingsIcon) {
chat.settingsIcon.remove();
chat.settingsIcon = null;
}
if (chat.overlays && chat.overlays.iframe) {
// Reset iframe clipping styles
const iframe = chat.overlays.iframe;
iframe.style.clipPath = '';
iframe.style.marginTop = '';
iframe.style.marginBottom = '';
iframe.style.height = '';
chat.overlays = null;
}
if (chat.resizeHandle) {
chat.resizeHandle.remove();
chat.resizeHandle = null;
}
if (chat.chatMode === 'normal') {
// Normal mode - show everything
// Show the header
if (chatHeader) {
chatHeader.style.display = 'flex';
}
// Restore window styling
chat.window.style.background = '#18181b';
chat.window.style.border = '1px solid #2f2f35';
chat.window.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)';
chat.window.style.borderRadius = '6px';
// Restore scrolling and resizing
chat.window.style.overflow = 'auto';
chat.window.style.resize = 'both';
// Restore iframe styling
if (chatIframe) {
chatIframe.style.borderRadius = '';
}
} else if (chat.chatMode === 'minimal') {
// Minimal mode - hide our window chrome only
// Hide the header
if (chatHeader) {
chatHeader.style.display = 'none';
}
// Remove window background and borders
chat.window.style.background = 'transparent';
chat.window.style.border = 'none';
chat.window.style.boxShadow = 'none';
chat.window.style.borderRadius = '0';
// Make iframe fill the entire window
if (chatIframe) {
chatIframe.style.borderRadius = '0';
}
// Create floating settings icon
createMinimalChatSettingsIcon(channel);
} else if (chat.chatMode === 'ultra') {
// Ultra-minimal mode - hide our chrome + Twitch UI elements
// Hide the header
if (chatHeader) {
chatHeader.style.display = 'none';
}
// Remove window background and borders
chat.window.style.background = 'transparent';
chat.window.style.border = 'none';
chat.window.style.boxShadow = 'none';
chat.window.style.borderRadius = '0';
// Disable scrolling and resizing in ultra mode
chat.window.style.overflow = 'hidden';
chat.window.style.resize = 'none';
// Make iframe fill the entire window
if (chatIframe) {
chatIframe.style.borderRadius = '0';
}
// Create overlays to hide Twitch UI elements
createChatOverlays(channel);
// Create small resize handle in bottom-right
createChatResizeHandle(channel);
// Create floating settings icon
createMinimalChatSettingsIcon(channel);
}
}
function createChatOverlays(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
const chatIframe = chat.window.querySelector('#offchat-chat-iframe');
if (!chatIframe) return;
// Use CSS clip-path to crop the iframe
// This hides: top 110px (notifications/top bar) and bottom 103px (input field)
chatIframe.style.clipPath = 'inset(110px 0px 103px 0px)';
// Adjust iframe to compensate for clipping
chatIframe.style.marginTop = '-110px';
chatIframe.style.marginBottom = '-103px';
chatIframe.style.height = 'calc(100% + 213px)'; // Add back clipped height
// Store reference (we'll store the iframe itself to reset later)
chat.overlays = { iframe: chatIframe };
}
function createChatResizeHandle(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
// Create small resize handle
const resizeHandle = document.createElement('div');
resizeHandle.id = `offchat-chat-resize-${channel}`;
resizeHandle.style.cssText = `
position: absolute;
bottom: 0;
right: 0;
width: 12px;
height: 12px;
background: rgba(145, 71, 255, 0.6);
cursor: nwse-resize;
z-index: 20;
border-top-left-radius: 3px;
pointer-events: auto;
`;
// Make it draggable for resizing
let isResizing = false;
let startX, startY, startWidth, startHeight;
let animationFrame = null;
const onMouseMove = (e) => {
if (!isResizing) return;
e.preventDefault();
// Use requestAnimationFrame for smoother resizing
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
animationFrame = requestAnimationFrame(() => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newWidth = Math.max(250, startWidth + dx);
const newHeight = Math.max(300, startHeight + dy);
chat.window.style.width = newWidth + 'px';
chat.window.style.height = newHeight + 'px';
chat.window.style.maxWidth = 'none';
chat.window.style.maxHeight = 'none';
});
};
const onMouseUp = () => {
if (isResizing) {
isResizing = false;
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
};
resizeHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startY = e.clientY;
const rect = chat.window.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
e.preventDefault();
e.stopPropagation();
// Add listeners only when actively resizing
document.addEventListener('mousemove', onMouseMove, { passive: false });
document.addEventListener('mouseup', onMouseUp);
});
// Hover effect
resizeHandle.onmouseenter = () => {
resizeHandle.style.background = 'rgba(145, 71, 255, 0.9)';
};
resizeHandle.onmouseleave = () => {
resizeHandle.style.background = 'rgba(145, 71, 255, 0.6)';
};
chat.window.appendChild(resizeHandle);
chat.resizeHandle = resizeHandle;
}
function createMinimalChatSettingsIcon(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
// Create floating settings icon
const settingsIcon = document.createElement('button');
settingsIcon.id = `offchat-minimal-chat-settings-${channel}`;
settingsIcon.title = chat.chatMode === 'minimal' ? 'Ultra-Minimal Mode' : 'Back to Normal';
settingsIcon.innerHTML = `
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 8a2 2 0 100 4 2 2 0 000-4zM2 10a2 2 0 114 0 2 2 0 01-4 0zm12 0a2 2 0 114 0 2 2 0 01-4 0z"/>
</svg>
`;
settingsIcon.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
border: 1px solid #2f2f35;
border-radius: 4px;
color: #bf94ff;
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: auto;
`;
settingsIcon.onclick = () => toggleChatMinimalMode(channel);
// Append to chat window
chat.window.appendChild(settingsIcon);
chat.settingsIcon = settingsIcon;
// Show icon on hover
chat.window.onmouseenter = () => {
if ((chat.chatMode === 'minimal' || chat.chatMode === 'ultra') && settingsIcon) {
settingsIcon.style.opacity = '1';
}
};
chat.window.onmouseleave = () => {
if ((chat.chatMode === 'minimal' || chat.chatMode === 'ultra') && settingsIcon) {
settingsIcon.style.opacity = '0';
}
};
// Hover effect for the icon itself
settingsIcon.onmouseenter = () => {
settingsIcon.style.background = 'rgba(145, 71, 255, 0.3)';
settingsIcon.style.color = '#fff';
};
settingsIcon.onmouseleave = () => {
settingsIcon.style.background = 'rgba(0, 0, 0, 0.7)';
settingsIcon.style.color = '#bf94ff';
};
}
function createChatMinimizeButton(channel) {
const chat = openChats.find(c => c.channel === channel);
if (!chat) return;
// Find index to calculate position
const chatIndex = openChats.findIndex(c => c.channel === channel);
const offset = chatIndex * 40; // 40px spacing for minimize buttons
const minimizeButton = document.createElement('button');
minimizeButton.id = `offchat-chat-minimize-btn-${channel}`;
minimizeButton.title = `Chat: ${channel}`;
minimizeButton.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M7.828 13L10 15.172 12.172 13H15a1 1 0 001-1V4a1 1 0 00-1-1H5a1 1 0 00-1 1v8a1 1 0 001 1h2.828zM10 18l-3-3H5a3 3 0 01-3-3V4a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3h-2l-3 3z"/>
</svg>
`;
minimizeButton.style.cssText = `
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: rgba(24, 24, 27, 0.95);
border: 1px solid #2f2f35;
border-radius: 4px;
color: #bf94ff;
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
padding: 0;
position: fixed;
top: ${100 + offset}px;
right: 20px;
z-index: 10001;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
`;
minimizeButton.onmouseenter = () => {
minimizeButton.style.background = 'rgba(83, 83, 95, 0.48)';
minimizeButton.style.color = '#fff';
};
minimizeButton.onmouseleave = () => {
minimizeButton.style.background = 'transparent';
minimizeButton.style.color = '#dedee3';
};
minimizeButton.onclick = () => showChatWindow(channel);
document.body.appendChild(minimizeButton);
chat.minimizeButton = minimizeButton;
}
function createControlPanel() {
if (controlPanel) controlPanel.remove();
controlPanel = document.createElement('div');
controlPanel.id = 'offchat-control-panel';
controlPanel.style.cssText = `
position: fixed;
bottom: 100px;
right: 370px;
background: #18181b;
border: 1px solid #2f2f35;
border-radius: 6px;
z-index: 10001;
font-family: 'Inter', 'Roobert', sans-serif;
color: #efeff1;
width: 240px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
overflow: hidden;
`;
document.body.appendChild(controlPanel);
updateControlPanel();
const header = controlPanel.querySelector('#offchat-header');
if (header) makeDraggable(controlPanel, header);
}
function updateControlPanel() {
if (!controlPanel) return;
controlPanel.innerHTML = `
<div id="offchat-header" style="
padding: 10px 12px;
background: #1f1f23;
border-bottom: 1px solid #2f2f35;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
cursor: move;
">
<div style="display: flex; align-items: center; gap: 8px;">
<button id="offchat-stream-icon" style="
background: none;
border: none;
color: ${currentPanelMode === 'stream' ? '#bf94ff' : '#898395'};
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
transition: color 0.1s ease-in;
" title="Stream Viewer">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 1.5h10a.5.5 0 01.5.5v10a.5.5 0 01-.5.5H5a.5.5 0 01-.5-.5V5a.5.5 0 01.5-.5z"/>
<path d="M8 7l5 3-5 3V7z"/>
</svg>
</button>
<button id="offchat-chat-icon" style="
background: none;
border: none;
color: ${currentPanelMode === 'chat' ? '#bf94ff' : '#898395'};
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
transition: color 0.1s ease-in;
" title="Chat Viewer">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M7.828 13L10 15.172 12.172 13H15a1 1 0 001-1V4a1 1 0 00-1-1H5a1 1 0 00-1 1v8a1 1 0 001 1h2.828zM10 18l-3-3H5a3 3 0 01-3-3V4a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3h-2l-3 3z"/>
</svg>
</button>
<button id="offchat-theater-icon" style="
background: none;
border: none;
color: ${isTheaterMode ? '#bf94ff' : '#898395'};
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
transition: color 0.1s ease-in;
" title="Theater Mode (T)">
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 4a2 2 0 012-2h12a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2V4zm2-0.5A.5.5 0 003.5 4v12a.5.5 0 00.5.5h12a.5.5 0 00.5-.5V4a.5.5 0 00-.5-.5H4z"/>
<path d="M6 6h8v8H6V6z"/>
</svg>
</button>
<span style="font-weight: 600; font-size: 13px; color: #dedee3;">${currentPanelMode === 'stream' ? 'Stream Viewer' : 'Chat Viewer'}</span>
</div>
<button id="offchat-close-panel" style="
background: none;
border: none;
color: #898395;
cursor: pointer;
font-size: 16px;
padding: 0;
line-height: 1;
" title="Minimize">−</button>
</div>
<div style="padding: 12px;">
${currentPanelMode === 'stream' && openStreams.length > 0 ? `
<div style="margin-bottom: 8px;">
<div style="font-size: 11px; color: #898395; margin-bottom: 6px;">Open Streams (${openStreams.length}/4)</div>
${openStreams.map((stream, index) => `
<div style="display: flex; align-items: center; gap: 4px; margin-bottom: 4px; padding: 6px; background: rgba(83, 83, 95, 0.2); border-radius: 4px;">
<span style="flex: 1; font-size: 12px; color: #efeff1;">${stream.displayName}${index === focusedStreamIndex ? ' ⭐' : ''}</span>
<button class="offchat-focus-stream-btn" data-index="${index}" style="
padding: 4px 8px;
height: 24px;
background: rgba(83, 83, 95, 0.38);
border: none;
border-radius: 12px;
color: #efeff1;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: background-color 0.1s ease-in;
">${index === focusedStreamIndex ? 'Grid' : 'Focus'}</button>
<button class="offchat-remove-stream-btn" data-id="${stream.id}" data-type="${stream.type}" style="
padding: 4px 8px;
height: 24px;
background: rgba(83, 83, 95, 0.38);
border: none;
border-radius: 12px;
color: #efeff1;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: background-color 0.1s ease-in;
">✕</button>
</div>
`).join('')}
</div>
${openStreams.length < 4 ? `
<input type="text" id="offchat-channel-input"
placeholder="Add another stream..."
style="
width: 100%;
padding: 8px 10px;
height: 30px;
background: rgba(14, 14, 16, 1);
border: 2px solid #464649;
border-radius: 4px;
color: #efeff1;
margin-bottom: 8px;
box-sizing: border-box;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.1s ease-in, background-color 0.1s ease-in;
">
<button id="offchat-embed-btn" style="
width: 100%;
padding: 5px 10px;
height: 30px;
background: #9147ff;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-weight: 600;
font-size: 13px;
font-family: inherit;
transition: background-color 0.1s ease-in, color 0.1s ease-in;
display: inline-flex;
align-items: center;
justify-content: center;
">+ Add Stream</button>
` : ''}
` : currentPanelMode === 'chat' && openChats.length > 0 ? `
<div style="margin-bottom: 8px;">
<div style="font-size: 11px; color: #898395; margin-bottom: 6px;">Open Chats (${openChats.length})</div>
${openChats.map(chat => `
<div style="display: flex; align-items: center; gap: 4px; margin-bottom: 4px; padding: 6px; background: rgba(83, 83, 95, 0.2); border-radius: 4px;">
<span style="flex: 1; font-size: 12px; color: #efeff1;">${chat.channel}</span>
<button class="offchat-toggle-chat-btn" data-channel="${chat.channel}" style="
padding: 4px 8px;
height: 24px;
background: rgba(83, 83, 95, 0.38);
border: none;
border-radius: 12px;
color: #efeff1;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: background-color 0.1s ease-in;
">${chat.isMinimized ? 'Show' : 'Hide'}</button>
<button class="offchat-remove-chat-btn" data-channel="${chat.channel}" style="
padding: 4px 8px;
height: 24px;
background: rgba(83, 83, 95, 0.38);
border: none;
border-radius: 12px;
color: #efeff1;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: background-color 0.1s ease-in;
">✕</button>
</div>
`).join('')}
</div>
<input type="text" id="offchat-channel-input"
placeholder="Open another chat..."
style="
width: 100%;
padding: 8px 10px;
height: 30px;
background: rgba(14, 14, 16, 1);
border: 2px solid #464649;
border-radius: 4px;
color: #efeff1;
margin-bottom: 8px;
box-sizing: border-box;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.1s ease-in, background-color 0.1s ease-in;
">
<button id="offchat-embed-btn" style="
width: 100%;
padding: 5px 10px;
height: 30px;
background: #9147ff;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-weight: 600;
font-size: 13px;
font-family: inherit;
transition: background-color 0.1s ease-in, color 0.1s ease-in;
display: inline-flex;
align-items: center;
justify-content: center;
">+ Open Chat</button>
` : `
<input type="text" id="offchat-channel-input"
placeholder="${currentPanelMode === 'stream' ? 'twitch.tv/... or channel name' : 'Chat: twitch.tv/... or channel name'}"
style="
width: 100%;
padding: 8px 10px;
height: 30px;
background: rgba(14, 14, 16, 1);
border: 2px solid #464649;
border-radius: 4px;
color: #efeff1;
margin-bottom: 8px;
box-sizing: border-box;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.1s ease-in, background-color 0.1s ease-in;
">
<button id="offchat-embed-btn" style="
width: 100%;
padding: 5px 10px;
height: 30px;
background: #9147ff;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
font-weight: 600;
font-size: 13px;
font-family: inherit;
transition: background-color 0.1s ease-in, color 0.1s ease-in;
display: inline-flex;
align-items: center;
justify-content: center;
">${currentPanelMode === 'stream' ? 'Watch' : 'Open Chat'}</button>
${config.lastStreams.length > 0 ? `
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #2f2f35;">
<div style="font-size: 11px; color: #898395; margin-bottom: 6px;">Recent</div>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
${config.lastStreams.slice(0, 6).map(s => `
<button class="offchat-recent-btn" data-channel="${s}" style="
padding: 4px 10px;
height: 24px;
background: rgba(83, 83, 95, 0.38);
border: none;
border-radius: 12px;
color: #efeff1;
cursor: pointer;
font-size: 12px;
font-weight: 600;
font-family: inherit;
transition: background-color 0.1s ease-in;
display: inline-flex;
align-items: center;
justify-content: center;
">${s}</button>
`).join('')}
</div>
</div>
` : ''}
`}
</div>
`;
const closePanel = document.getElementById('offchat-close-panel');
const embedBtn = document.getElementById('offchat-embed-btn');
const channelInput = document.getElementById('offchat-channel-input');
const focusStreamBtns = document.querySelectorAll('.offchat-focus-stream-btn');
const removeStreamBtns = document.querySelectorAll('.offchat-remove-stream-btn');
const toggleChatBtns = document.querySelectorAll('.offchat-toggle-chat-btn');
const removeChatBtns = document.querySelectorAll('.offchat-remove-chat-btn');
const recentBtns = document.querySelectorAll('.offchat-recent-btn');
const header = document.getElementById('offchat-header');
const streamIcon = document.getElementById('offchat-stream-icon');
const chatIcon = document.getElementById('offchat-chat-icon');
const theaterIcon = document.getElementById('offchat-theater-icon');
if (streamIcon) {
streamIcon.onclick = () => {
currentPanelMode = 'stream';
updateControlPanel();
};
streamIcon.onmouseenter = () => streamIcon.style.color = '#a970ff';
streamIcon.onmouseleave = () => streamIcon.style.color = currentPanelMode === 'stream' ? '#bf94ff' : '#898395';
}
if (chatIcon) {
chatIcon.onclick = () => {
currentPanelMode = 'chat';
updateControlPanel();
};
chatIcon.onmouseenter = () => chatIcon.style.color = '#a970ff';
chatIcon.onmouseleave = () => chatIcon.style.color = currentPanelMode === 'chat' ? '#bf94ff' : '#898395';
}
if (theaterIcon) {
theaterIcon.onclick = () => {
toggleTheaterMode();
};
theaterIcon.onmouseenter = () => theaterIcon.style.color = '#a970ff';
theaterIcon.onmouseleave = () => theaterIcon.style.color = isTheaterMode ? '#bf94ff' : '#898395';
}
if (closePanel) {
closePanel.onclick = () => {
controlPanel.style.display = 'none';
createShowButton();
};
}
if (embedBtn && channelInput) {
embedBtn.onclick = () => {
const value = channelInput.value.trim();
if (value) {
if (currentPanelMode === 'stream') {
createEmbedPlayer(value);
} else {
createChatWindow(value);
}
} else {
channelInput.style.borderColor = '#9147ff';
channelInput.focus();
}
};
embedBtn.onmouseenter = () => embedBtn.style.background = '#772ce8';
embedBtn.onmouseleave = () => embedBtn.style.background = '#9147ff';
channelInput.onkeydown = (e) => { if (e.key === 'Enter') embedBtn.click(); };
channelInput.onfocus = () => {
channelInput.style.borderColor = '#a970ff';
channelInput.style.background = 'rgba(14, 14, 16, 1)';
};
channelInput.onblur = () => {
channelInput.style.borderColor = '#464649';
channelInput.style.background = 'rgba(14, 14, 16, 1)';
};
}
focusStreamBtns.forEach(btn => {
const index = parseInt(btn.dataset.index);
btn.onclick = () => toggleStreamFocus(index);
btn.onmouseenter = () => btn.style.background = 'rgba(83, 83, 95, 0.48)';
btn.onmouseleave = () => btn.style.background = 'rgba(83, 83, 95, 0.38)';
});
removeStreamBtns.forEach(btn => {
const streamId = btn.dataset.id;
const streamType = btn.dataset.type;
btn.onclick = () => removeStream(streamId, streamType);
btn.onmouseenter = () => btn.style.background = 'rgba(83, 83, 95, 0.48)';
btn.onmouseleave = () => btn.style.background = 'rgba(83, 83, 95, 0.38)';
});
toggleChatBtns.forEach(btn => {
const channel = btn.dataset.channel;
btn.onclick = () => {
const chat = openChats.find(c => c.channel === channel);
if (chat) {
if (chat.isMinimized) {
showChatWindow(channel);
} else {
minimizeChatWindow(channel);
}
}
};
btn.onmouseenter = () => btn.style.background = 'rgba(83, 83, 95, 0.48)';
btn.onmouseleave = () => btn.style.background = 'rgba(83, 83, 95, 0.38)';
});
removeChatBtns.forEach(btn => {
const channel = btn.dataset.channel;
btn.onclick = () => removeChatWindow(channel);
btn.onmouseenter = () => btn.style.background = 'rgba(83, 83, 95, 0.48)';
btn.onmouseleave = () => btn.style.background = 'rgba(83, 83, 95, 0.38)';
});
recentBtns.forEach(btn => {
btn.onclick = () => {
if (currentPanelMode === 'stream') {
createEmbedPlayer(btn.dataset.channel);
} else {
createChatWindow(btn.dataset.channel);
}
};
btn.onmouseenter = () => btn.style.background = 'rgba(83, 83, 95, 0.48)';
btn.onmouseleave = () => btn.style.background = 'rgba(83, 83, 95, 0.38)';
});
if (header) makeDraggable(controlPanel, header);
}
function createShowButton() {
let btn = document.getElementById('offchat-show-btn');
if (btn && document.body.contains(btn)) {
btn.style.display = 'inline-flex';
btn.style.visibility = 'visible';
return;
}
if (btn) btn.remove();
btn = document.createElement('button');
btn.id = 'offchat-show-btn';
btn.title = 'Offchat Viewer';
btn.innerHTML = `
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 1.5h10a.5.5 0 01.5.5v10a.5.5 0 01-.5.5H5a.5.5 0 01-.5-.5V5a.5.5 0 01.5-.5z"/>
<path d="M8 7l5 3-5 3V7z"/>
</svg>
`;
btn.style.cssText = `
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: transparent;
border: none;
border-radius: 4px;
color: #dedee3;
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
padding: 0;
margin-left: 5px;
vertical-align: middle;
visibility: visible !important;
opacity: 1 !important;
z-index: 9999;
`;
btn.onmouseenter = () => {
btn.style.background = 'rgba(83, 83, 95, 0.48)';
btn.style.color = '#fff';
};
btn.onmouseleave = () => {
btn.style.background = 'transparent';
btn.style.color = '#dedee3';
};
btn.onclick = () => {
if (controlPanel) {
controlPanel.style.display = 'block';
btn.style.display = 'none';
}
};
const insertButton = () => {
// Remove any existing button first
const existingBtn = document.getElementById('offchat-show-btn');
if (existingBtn && existingBtn !== btn) {
existingBtn.remove();
}
// Try multiple selectors for the share button
const shareBtn = document.querySelector('[data-a-target="share-button"]') ||
document.querySelector('[aria-label="Share"]') ||
document.querySelector('button[aria-label*="Share"]') ||
document.querySelector('button[data-test-selector="share-button"]');
if (shareBtn && shareBtn.parentNode) {
shareBtn.parentNode.insertBefore(btn, shareBtn.nextSibling);
console.log('[Offchat] Button inserted next to share button');
return true;
}
// Try channel header buttons area
const channelButtons = document.querySelector('.channel-header__user-tab-content .tw-align-items-center') ||
document.querySelector('[data-a-target="channel-header-right"]') ||
document.querySelector('.channel-info-content__action-container');
if (channelButtons) {
channelButtons.appendChild(btn);
console.log('[Offchat] Button inserted in channel buttons area');
return true;
}
// Try metadata layout
const metadataLayout = document.querySelector('.metadata-layout__secondary-button-spacing');
if (metadataLayout) {
metadataLayout.appendChild(btn);
console.log('[Offchat] Button inserted in metadata layout');
return true;
}
return false;
};
if (!insertButton()) {
// Keep trying very aggressively
let attempts = 0;
const retryInterval = setInterval(() => {
attempts++;
if (document.body.contains(btn)) {
// Button exists, try to relocate it to proper position
const shareBtn = document.querySelector('[data-a-target="share-button"]');
if (shareBtn && shareBtn.parentNode) {
// Check if button is not already next to share button
if (shareBtn.nextSibling !== btn) {
// Remove from current position and insert next to share
btn.remove();
shareBtn.parentNode.insertBefore(btn, shareBtn.nextSibling);
console.log('[Offchat] Button relocated next to share button');
}
// Button is in correct position, stop trying
clearInterval(retryInterval);
return;
}
} else {
// Button doesn't exist, try to insert it
if (insertButton()) {
clearInterval(retryInterval);
return;
}
// After 5 attempts, use fallback position
if (attempts === 5) {
btn.style.cssText += `
position: fixed !important;
top: 60px !important;
right: 20px !important;
z-index: 10001 !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.5) !important;
background: rgba(24, 24, 27, 0.95) !important;
`;
document.body.appendChild(btn);
console.log('[Offchat] Using fallback position (top-right)');
// Don't stop trying, continue to relocate if possible
}
}
// Give up after 60 attempts (30 seconds)
if (attempts > 60) {
clearInterval(retryInterval);
console.log('[Offchat] Stopped trying to relocate button after 60 attempts');
}
}, 500);
}
}
function cleanup() {
removeStreamContainer();
// Remove all chat windows
[...openChats].forEach(chat => removeChatWindow(chat.channel));
if (controlPanel) { controlPanel.remove(); controlPanel = null; }
const showBtn = document.getElementById('offchat-show-btn');
if (showBtn) showBtn.remove();
if (buttonCheckInterval) { clearInterval(buttonCheckInterval); buttonCheckInterval = null; }
currentPanelMode = 'stream';
streamWrapperCache = {}; // Clear wrapper cache
if (resizeUpdateBound) {
window.removeEventListener('resize', resizeUpdateBound);
window.removeEventListener('scroll', resizeUpdateBound);
resizeUpdateBound = null;
}
// Exit theater mode if active
if (isTheaterMode) {
toggleTheaterMode();
}
}
function toggleTheaterMode() {
isTheaterMode = !isTheaterMode;
if (isTheaterMode) {
// Enter theater mode
// Create style to hide Twitch UI
if (!theaterModeStyle) {
theaterModeStyle = document.createElement('style');
theaterModeStyle.id = 'offchat-theater-mode';
document.head.appendChild(theaterModeStyle);
}
theaterModeStyle.textContent = `
/* Hide Twitch navigation and UI */
nav[aria-label="Primary Navigation"],
.top-nav,
[data-a-target="top-nav-container"],
.twilight-minimal-root .top-nav__menu,
.side-nav,
[data-a-target="side-nav"],
.side-nav-section,
.channel-root__right-column,
.stream-chat,
[data-a-target="right-column-chat-bar"],
.video-chat__overlay,
.channel-info-content,
.channel-leaderboard,
[data-a-target="channel-header"],
.about-section,
.simplebar-scroll-content,
.channel-root__player-footer,
.chat-shell,
.stream-chat-header,
.persistent-player,
[data-a-target="video-player"],
.video-player,
.video-player__container {
display: none !important;
}
/* Ensure body takes full space */
body {
overflow: hidden !important;
}
/* Make our stream container take full viewport */
#offchat-embed-wrapper {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 999999 !important;
}
/* Ensure chats and control panel stay visible */
#offchat-control-panel,
[id^="offchat-chat-window-"],
[id^="offchat-chat-minimize-btn-"],
#offchat-show-btn {
z-index: 10000000 !important;
}
`;
// Enter fullscreen to hide browser chrome
if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(err => {
console.log('[Offchat] Fullscreen request failed:', err);
});
}
// Update streams to fill viewport
if (streamContainer) {
renderStreamContainer();
}
// Show notification
showTheaterModeNotification('Theater Mode Enabled - Press T to exit');
// Update control panel to reflect theater mode
updateControlPanel();
} else {
// Exit theater mode
// Remove theater mode styles completely
if (theaterModeStyle && theaterModeStyle.parentNode) {
theaterModeStyle.remove();
theaterModeStyle = null;
}
// Exit fullscreen
if (document.fullscreenElement) {
document.exitFullscreen().catch(err => {
console.log('[Offchat] Exit fullscreen failed:', err);
});
}
// Force a reflow to ensure CSS is cleared before re-rendering
// Wait a bit for Twitch UI to reappear
setTimeout(() => {
// Update streams to normal size
if (streamContainer) {
renderStreamContainer();
}
}, 100);
// Show notification
showTheaterModeNotification('Theater Mode Disabled');
// Update control panel to reflect theater mode
updateControlPanel();
}
}
function showTheaterModeNotification(message) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-family: 'Inter', 'Roobert', sans-serif;
font-size: 14px;
font-weight: 600;
z-index: 100000000;
border: 2px solid #9147ff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
pointer-events: none;
animation: offchat-fade-in-out 2s ease-in-out;
`;
notification.textContent = message;
// Add animation
const animStyle = document.createElement('style');
animStyle.textContent = `
@keyframes offchat-fade-in-out {
0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
85% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
}
`;
document.head.appendChild(animStyle);
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
animStyle.remove();
}, 2000);
}
function toggleMainStreamHide() {
isMainStreamHidden = !isMainStreamHidden;
const playerContainer = document.querySelector('[data-a-target="video-player"]') ||
document.querySelector('.video-player__container') ||
document.querySelector('.video-player') ||
document.querySelector('.persistent-player');
if (playerContainer) {
if (isMainStreamHidden) {
playerContainer.style.opacity = '0';
playerContainer.style.pointerEvents = 'none';
// Try to pause the video
const video = playerContainer.querySelector('video');
if (video) {
video.pause();
video.muted = true;
}
showTheaterModeNotification('Main Stream Hidden');
} else {
playerContainer.style.opacity = '1';
playerContainer.style.pointerEvents = 'auto';
showTheaterModeNotification('Main Stream Visible');
}
}
// Update control panel if it exists
if (controlPanel) updateControlPanel();
}
function toggleSyncMute() {
if (openStreams.length === 0) {
showTheaterModeNotification('No streams open to mute');
return;
}
isMuted = !isMuted;
// Send mute/unmute commands to all stream iframes
openStreams.forEach(stream => {
const streamKey = `${stream.type}-${stream.id}`;
const wrapper = streamWrapperCache[streamKey];
if (!wrapper) return;
const iframe = wrapper.querySelector('iframe');
if (!iframe || !iframe.contentWindow) return;
if (stream.type === 'twitch') {
// Use Twitch Embed postMessage API
// Twitch player listens for these commands
try {
iframe.contentWindow.postMessage(
JSON.stringify({
namespace: 'twitch-embed-player-proxy',
method: isMuted ? 'setMuted' : 'setMuted',
arguments: [isMuted]
}),
'https://player.twitch.tv'
);
} catch (e) {
console.log('[Offchat] Failed to send mute command to Twitch iframe:', e);
}
} else if (stream.type === 'youtube') {
// Use YouTube IFrame API postMessage
try {
iframe.contentWindow.postMessage(
JSON.stringify({
event: 'command',
func: isMuted ? 'mute' : 'unMute',
args: []
}),
'https://www.youtube.com'
);
} catch (e) {
console.log('[Offchat] Failed to send mute command to YouTube iframe:', e);
}
}
});
// Show notification
showTheaterModeNotification(isMuted ? 'All Streams Muted' : 'All Streams Unmuted');
// Update control panel to reflect mute state
if (controlPanel) updateControlPanel();
}
function init() {
if (!isChannelPage()) return;
currentChannel = getCurrentChannel();
if (!currentChannel) return;
lastChannel = currentChannel;
setTimeout(() => {
createControlPanel();
controlPanel.style.display = 'none';
createShowButton();
// More frequent monitoring (every 1 second)
buttonCheckInterval = setInterval(() => {
const btn = document.getElementById('offchat-show-btn');
if (!btn || !document.body.contains(btn)) {
console.log('[Offchat] Button missing, recreating...');
createShowButton();
} else {
// Check if button is visible
const styles = window.getComputedStyle(btn);
if (styles.display === 'none' || styles.visibility === 'hidden' || styles.opacity === '0') {
console.log('[Offchat] Button hidden, making visible...');
btn.style.display = 'inline-flex';
btn.style.visibility = 'visible';
btn.style.opacity = '1';
}
// Try to relocate if not next to share button
const shareBtn = document.querySelector('[data-a-target="share-button"]');
if (shareBtn && shareBtn.parentNode && shareBtn.nextSibling !== btn) {
const currentPos = btn.style.position;
// Only relocate if not in fallback fixed position
if (currentPos !== 'fixed') {
btn.remove();
shareBtn.parentNode.insertBefore(btn, shareBtn.nextSibling);
console.log('[Offchat] Button relocated to correct position');
}
}
}
}, 1000);
}, 2000);
}
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Ignore if user is typing in an input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
return;
}
// Toggle theater mode with 'T' key
if (e.key === 't' || e.key === 'T') {
e.preventDefault();
toggleTheaterMode();
}
// Toggle sync mute with 'M' key
if (e.key === 'm' || e.key === 'M') {
e.preventDefault();
toggleSyncMute();
}
// Toggle control panel with 'P' key
if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
if (controlPanel) {
if (controlPanel.style.display === 'none') {
controlPanel.style.display = 'block';
const showBtn = document.getElementById('offchat-show-btn');
if (showBtn) showBtn.style.display = 'none';
} else {
controlPanel.style.display = 'none';
createShowButton();
}
}
}
// Focus streams with number keys 1-4
if (['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
const index = parseInt(e.key) - 1;
if (index < openStreams.length) {
toggleStreamFocus(index);
}
}
// Close all streams with Shift+X
if (e.shiftKey && (e.key === 'x' || e.key === 'X')) {
e.preventDefault();
if (openStreams.length > 0) {
if (confirm(`Close all ${openStreams.length} stream(s)?`)) {
removeStreamContainer();
updateControlPanel();
}
}
}
// Switch to stream viewer mode with 'S'
if (e.key === 's' || e.key === 'S') {
e.preventDefault();
currentPanelMode = 'stream';
if (controlPanel) updateControlPanel();
}
// Switch to chat viewer mode with 'C'
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
currentPanelMode = 'chat';
if (controlPanel) updateControlPanel();
}
// Return to grid mode with 'G'
if (e.key === 'g' || e.key === 'G') {
e.preventDefault();
if (focusedStreamIndex !== null) {
focusedStreamIndex = null;
renderStreamContainer();
}
}
// Toggle main stream hide with 'H' key
if (e.key === 'h' || e.key === 'H') {
e.preventDefault();
toggleMainStreamHide();
}
});
// Also handle fullscreen change events (user presses ESC)
document.addEventListener('fullscreenchange', () => {
// If user exits fullscreen manually, also exit theater mode
if (!document.fullscreenElement && isTheaterMode) {
isTheaterMode = false;
if (theaterModeStyle && theaterModeStyle.parentNode) {
theaterModeStyle.remove();
theaterModeStyle = null;
}
// Wait for Twitch UI to reappear before re-rendering
setTimeout(() => {
if (streamContainer) {
renderStreamContainer();
}
}, 100);
if (controlPanel) {
updateControlPanel();
}
}
// If a stream exits fullscreen, re-render to fix dimensions
if (!document.fullscreenElement && streamContainer && !isTheaterMode) {
setTimeout(() => {
if (streamContainer) {
renderStreamContainer();
}
}, 100);
}
});
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
const newChannel = getCurrentChannel();
if (newChannel !== lastChannel) {
cleanup();
lastChannel = newChannel;
setTimeout(init, 1500);
} else if (newChannel) {
// Tab switched on same channel - recreate button
console.log('[Offchat] Tab changed, ensuring button exists');
setTimeout(() => {
const btn = document.getElementById('offchat-show-btn');
if (!btn || !document.body.contains(btn)) {
createShowButton();
} else {
// Button exists but might be in wrong place, try to relocate
const shareBtn = document.querySelector('[data-a-target="share-button"]');
if (shareBtn && shareBtn.parentNode && !shareBtn.parentNode.contains(btn)) {
btn.remove();
createShowButton();
}
}
}, 1500);
}
}
}).observe(document, { subtree: true, childList: true });
GM_registerMenuCommand('Clear Recent Streams', () => {
config.lastStreams = [];
GM_setValue('lastStreams', []);
updateControlPanel();
});
GM_registerMenuCommand('Show Panel', () => {
if (controlPanel) {
controlPanel.style.display = 'block';
const showBtn = document.getElementById('offchat-show-btn');
if (showBtn) showBtn.style.display = 'none';
}
});
GM_registerMenuCommand('Toggle Theater Mode (T)', () => {
toggleTheaterMode();
});
GM_registerMenuCommand('Toggle Sync Mute (M)', () => {
toggleSyncMute();
});
GM_registerMenuCommand('Show Keyboard Shortcuts', () => {
const shortcuts = `
Keyboard Shortcuts:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T - Toggle Theater Mode
M - Toggle Sync Mute All
P - Toggle Control Panel
G - Return to Grid Mode
1-4 - Focus Stream 1-4
Shift+X - Close All Streams
S - Switch to Stream Viewer
C - Switch to Chat Viewer
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`;
alert(shortcuts);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();