Watch another stream while chilling in an offline chat
目前為
// ==UserScript==
// @name Twitch Offchat Stream Viewer
// @namespace http://tampermonkey.net/
// @version 1.0
// @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 isEmbedded = false;
let currentChannel = '';
let controlPanel = null;
let lastChannel = null;
let buttonCheckInterval = null;
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 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';
});
}
function createEmbedPlayer(channelInput) {
if (!channelInput) return;
const channel = extractChannelName(channelInput);
if (!channel) return;
if (channel === currentChannel) {
alert("That's the channel you're already in!");
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);
const existing = document.getElementById('offchat-embed-wrapper');
if (existing) existing.remove();
const playerContainer = document.querySelector('[data-a-target="video-player"]') ||
document.querySelector('.video-player__container') ||
document.querySelector('.video-player') ||
document.querySelector('.persistent-player');
const wrapper = document.createElement('div');
wrapper.id = 'offchat-embed-wrapper';
if (playerContainer) {
const rect = playerContainer.getBoundingClientRect();
wrapper.style.cssText = `
position: fixed !important;
top: ${rect.top}px !important;
left: ${rect.left}px !important;
width: ${rect.width}px !important;
height: ${rect.height}px !important;
max-width: ${rect.width}px !important;
max-height: ${rect.height}px !important;
min-width: ${rect.width}px !important;
min-height: ${rect.height}px !important;
z-index: 1000 !important;
background: #0e0e10 !important;
overflow: hidden !important;
box-sizing: border-box !important;
display: block !important;
margin: 0 !important;
padding: 0 !important;
`;
} else {
const chatWidth = 340;
const navHeight = 50;
wrapper.style.cssText = `
position: fixed;
top: ${navHeight}px;
left: 0;
width: calc(100vw - ${chatWidth}px);
height: calc(100vh - ${navHeight}px);
z-index: 999;
background: #0e0e10;
overflow: hidden;
`;
}
document.body.appendChild(wrapper);
const iframe = document.createElement('iframe');
iframe.id = 'offchat-twitch-iframe';
iframe.src = `https://player.twitch.tv/?channel=${channel}&parent=www.twitch.tv&muted=false&autoplay=true`;
let iframeWidth, iframeHeight;
if (playerContainer) {
const pRect = playerContainer.getBoundingClientRect();
iframeWidth = pRect.width;
iframeHeight = pRect.height;
} else {
iframeWidth = window.innerWidth - 340;
iframeHeight = window.innerHeight - 50;
}
iframe.style.cssText = `
width: ${iframeWidth}px !important;
height: ${iframeHeight}px !important;
max-width: ${iframeWidth}px !important;
max-height: ${iframeHeight}px !important;
min-width: ${iframeWidth}px !important;
min-height: ${iframeHeight}px !important;
border: none !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
display: block !important;
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box !important;
`;
iframe.width = iframeWidth;
iframe.height = iframeHeight;
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('allow', 'autoplay; fullscreen');
wrapper.appendChild(iframe);
if (playerContainer) {
const updatePosition = () => {
const rect = playerContainer.getBoundingClientRect();
wrapper.style.top = rect.top + 'px';
wrapper.style.left = rect.left + 'px';
wrapper.style.width = rect.width + 'px';
wrapper.style.height = rect.height + 'px';
iframe.style.width = rect.width + 'px';
iframe.style.height = rect.height + 'px';
iframe.style.maxWidth = rect.width + 'px';
iframe.style.maxHeight = rect.height + 'px';
};
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition);
}
isEmbedded = true;
updateControlPanel();
}
function removeEmbedPlayer() {
const wrapper = document.getElementById('offchat-embed-wrapper');
if (wrapper) wrapper.remove();
isEmbedded = false;
updateControlPanel();
}
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;">
<span style="font-size: 14px;">🎬</span>
<span style="font-weight: 600; font-size: 13px; color: #dedee3;">Offchat 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;">
<div style="font-size: 11px; color: #898395; margin-bottom: 12px;">
In: <span style="color: #bf94ff;">${currentChannel}</span>
</div>
${isEmbedded ? `
<button id="offchat-stop-btn" style="
width: 100%;
padding: 8px 12px;
background: #3d3d3d;
border: none;
border-radius: 4px;
color: #efeff1;
cursor: pointer;
font-size: 13px;
font-weight: 500;
">✕ Close Stream</button>
` : `
<input type="text" id="offchat-channel-input"
placeholder="twitch.tv/... or channel name"
style="
width: 100%;
padding: 8px 10px;
background: #0e0e10;
border: 1px solid #2f2f35;
border-radius: 4px;
color: #efeff1;
margin-bottom: 8px;
box-sizing: border-box;
font-size: 13px;
outline: none;
">
<button id="offchat-embed-btn" style="
width: 100%;
padding: 8px 12px;
background: #9147ff;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
font-weight: 600;
font-size: 13px;
">Watch</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 8px;
background: #2f2f35;
border: none;
border-radius: 4px;
color: #dedee3;
cursor: pointer;
font-size: 11px;
">${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 stopBtn = document.getElementById('offchat-stop-btn');
const recentBtns = document.querySelectorAll('.offchat-recent-btn');
const header = document.getElementById('offchat-header');
if (closePanel) {
closePanel.onclick = () => {
controlPanel.style.display = 'none';
createShowButton();
};
}
if (embedBtn && channelInput) {
embedBtn.onclick = () => {
const value = channelInput.value.trim();
if (value) createEmbedPlayer(value);
else {
channelInput.style.borderColor = '#9147ff';
channelInput.focus();
}
};
channelInput.onkeydown = (e) => { if (e.key === 'Enter') embedBtn.click(); };
channelInput.onfocus = () => channelInput.style.borderColor = '#9147ff';
channelInput.onblur = () => channelInput.style.borderColor = '#2f2f35';
}
if (stopBtn) {
stopBtn.onclick = () => {
removeEmbedPlayer();
createControlPanel();
};
}
recentBtns.forEach(btn => {
btn.onclick = () => createEmbedPlayer(btn.dataset.channel);
btn.onmouseenter = () => btn.style.background = '#3d3d3d';
btn.onmouseleave = () => btn.style.background = '#2f2f35';
});
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';
return;
}
if (btn) btn.remove();
btn = document.createElement('button');
btn.id = 'offchat-show-btn';
btn.title = 'Offchat Viewer - Watch another stream here';
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;
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;
`;
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 = () => {
const shareBtn = document.querySelector('[data-a-target="share-button"]') ||
document.querySelector('[aria-label="Share"]') ||
document.querySelector('button[aria-label*="Share"]');
if (shareBtn && shareBtn.parentNode) {
shareBtn.parentNode.insertBefore(btn, shareBtn.nextSibling);
return true;
}
const actionsArea = document.querySelector('.metadata-layout__secondary-button-spacing') ||
document.querySelector('[data-target="channel-header-right"]') ||
document.querySelector('.channel-info-content');
if (actionsArea) {
actionsArea.appendChild(btn);
return true;
}
return false;
};
if (!insertButton()) {
let attempts = 0;
const retryInterval = setInterval(() => {
attempts++;
if (insertButton() || attempts > 10) {
clearInterval(retryInterval);
if (!btn.parentNode) {
btn.style.cssText += `
position: fixed;
top: 10px;
left: 240px;
z-index: 10001;
background: rgba(24, 24, 27, 0.8);
`;
document.body.appendChild(btn);
}
}
}, 500);
}
}
function cleanup() {
removeEmbedPlayer();
if (controlPanel) { controlPanel.remove(); controlPanel = null; }
const showBtn = document.getElementById('offchat-show-btn');
if (showBtn) showBtn.remove();
if (buttonCheckInterval) { clearInterval(buttonCheckInterval); buttonCheckInterval = null; }
}
function init() {
if (!isChannelPage()) return;
currentChannel = getCurrentChannel();
if (!currentChannel) return;
lastChannel = currentChannel;
setTimeout(() => {
createControlPanel();
controlPanel.style.display = 'none';
createShowButton();
buttonCheckInterval = setInterval(() => {
const btn = document.getElementById('offchat-show-btn');
if (!btn || !document.body.contains(btn)) {
createShowButton();
}
}, 2000);
}, 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) {
setTimeout(() => {
const btn = document.getElementById('offchat-show-btn');
if (!btn || !document.body.contains(btn)) {
createShowButton();
}
}, 1000);
}
}
}).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();
}
})();