Watch another stream/video while chilling in an offline chat
当前为
// ==UserScript==
// @name Twitch Offchat Stream Viewer
// @namespace http://tampermonkey.net/
// @version 1.1
// @description Watch another stream/video 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 }
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
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;
if (channel === currentChannel) {
alert("That's the channel you're already in!");
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();
}
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 playerContainer = document.querySelector('[data-a-target="video-player"]') ||
document.querySelector('.video-player__container') ||
document.querySelector('.video-player') ||
document.querySelector('.persistent-player');
const numStreams = openStreams.length;
const containerPadding = numStreams === 1 ? '0px' : '8px';
let containerRect;
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';
if (playerContainer) {
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;
`;
} else {
streamContainer.style.cssText = `
position: fixed;
top: ${containerRect.top}px;
left: 0;
width: calc(100vw - 340px);
height: calc(100vh - 50px);
z-index: 999;
background: #0e0e10;
overflow: hidden;
padding: ${containerPadding};
box-sizing: border-box;
`;
}
document.body.appendChild(streamContainer);
} else {
// Update existing container styling
streamContainer = existing;
streamContainer.style.padding = containerPadding;
if (playerContainer) {
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`;
} 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 (playerContainer && !resizeUpdateBound) {
resizeUpdateBound = () => {
if (!streamContainer || !playerContainer) return;
const rect = playerContainer.getBoundingClientRect();
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;
if (channel === currentChannel) {
alert("That's the channel you're already in!");
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-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 minimizeBtn = chatWindow.querySelector('.offchat-minimize-chat-btn');
const closeBtn = chatWindow.querySelector('.offchat-close-chat-btn');
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
});
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();
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 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>
<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');
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 (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;
}
}
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);
}
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';
}
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();