High-performance terminal-style YouTube live chat interface with custom themes, message pruning, and power-user controls.
// ==UserScript==
// @name Terminal Chat
// @namespace https://castor-tm.neocities.org/
// @version v0.9.86
// @description High-performance terminal-style YouTube live chat interface with custom themes, message pruning, and power-user controls.
// @author CastorWD
// @license CC-BY-NC-SA-4.0
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant none
// @run-at document-end
// @noframes
// ==/UserScript==
(function() {
'use strict';
let policy = { createHTML: (s) => s };
if (window.trustedTypes?.createPolicy) {
try { policy = window.trustedTypes.createPolicy('chatPolicy', { createHTML: (s) => s }); } catch (e) {}
}
let showTimestamps = false;
let filterTerm = "";
let lastItems = null;
let activeObserver = null;
let currentChannel = "";
let activeParticipants = new Map();
let mutedUsers = new Set(JSON.parse(localStorage.getItem('yt-terminal-muted') || "[]"));
const defaultEmojis = ["😂", "🔥", "🚀", "💯", "🦅", "🎲", "❤️", "✅", "🫡", "👍", "😮", "🙏"];
let emojiList = JSON.parse(localStorage.getItem('yt-terminal-emojis')) || defaultEmojis;
let isPaused = false;
let messageBuffer = [];
let isLockedToBottom = true;
let isTicking = false;
let isAutoScrolling = false;
let isResizing = false;
let resizeTimer = null;
let cmdHistory = JSON.parse(localStorage.getItem('yt-terminal-history') || "[]");
let cmdIndex = cmdHistory.length;
let savedGeom = JSON.parse(localStorage.getItem('yt-terminal-geom')) || { top: '100px', left: '100px', width: '450px', height: '600px', isSnapped: true };
let savedSizes = JSON.parse(localStorage.getItem('yt-terminal-sizes')) || { font: '13px', emo: '15px', bgLight: 0, nameColor: '#55A34D', msgColor: '#eeeeee', highlights: '', msgLimit: 500 };
if (savedSizes.txtColor) {
savedSizes.nameColor = savedSizes.txtColor;
savedSizes.msgColor = '#eeeeee';
delete savedSizes.txtColor;
localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes));
}
const saveMuted = () => localStorage.setItem('yt-terminal-muted', JSON.stringify([...mutedUsers]));
let scrollRafId = null;
let expectedScrollTop = -1;
const scrollToBottom = () => {
const stream = document.getElementById('term-stream');
if (!stream) return;
// Calculate exactly where the bottom is
const targetTop = stream.scrollHeight - stream.clientHeight;
expectedScrollTop = targetTop; // Log our intended destination
stream.scrollTop = targetTop;
};
const purgeMuted = (user) => {
const stream = document.getElementById('term-stream');
if (!stream) return;
const msgs = stream.querySelectorAll('.term-msg');
msgs.forEach(m => {
const auth = m.querySelector('.t-auth');
if (auth && auth.textContent.replace(':', '').trim() === user) {
m.remove();
}
});
};
const sendToNative = (text) => {
const chatFrame = document.querySelector('ytd-live-chat-frame iframe');
const chatDoc = chatFrame?.contentDocument || chatFrame?.contentWindow?.document;
const inputField = chatDoc?.querySelector('#input.yt-live-chat-text-input-field-renderer');
const sendBtn = chatDoc?.querySelector('#send-button button');
if (inputField && sendBtn) {
inputField.focus();
inputField.textContent = text; // Direct injection
inputField.dispatchEvent(new InputEvent('input', { bubbles: true, data: text }));
setTimeout(() => {
if (sendBtn.hasAttribute('disabled')) sendBtn.removeAttribute('disabled');
sendBtn.click();
inputField.textContent = "";
}, 250);
}
};
const syncStyle = (el) => {
if (!el) return;
el.style.fontSize = savedSizes.font;
const hideEmo = savedSizes.emo === '0px' || savedSizes.emo === 'dot';
const showDot = savedSizes.emo === 'dot';
el.querySelectorAll('img, .emoji, .yt-emoji-icon').forEach(img => {
if (hideEmo) {
img.style.display = 'none';
} else {
img.style.display = 'inline-block';
img.style.width = savedSizes.emo;
img.style.height = savedSizes.emo;
img.style.verticalAlign = 'middle';
img.style.position = 'relative';
img.style.top = '-1px';
}
});
el.querySelectorAll('.e-dot').forEach(dot => {
dot.style.display = showDot ? 'inline' : 'none';
});
const t = el.querySelector('.t-time');
if (t) t.style.display = showTimestamps ? 'inline' : 'none';
if (filterTerm && !el.classList.contains('session-break')) {
el.classList.toggle('t-hide', !el.textContent.toLowerCase().includes(filterTerm));
}
};
const applyStyles = () => {
if (document.getElementById('term-core-css')) return;
const style = document.createElement('style');
style.id = 'term-core-css';
style.textContent = `
/* 1. Host Wrapper Anchoring & Stacking Context Fix */
#chat {
position: relative !important;
z-index: 2147483647 !important; /* Elevate the entire chat parent */
transform: translateZ(0); /* Force hardware stacking context */
}
#panels {
position: relative !important;
z-index: 1 !important; /* Demote all side panels */
}
/* Annihilate the sponsored ad panels */
ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"] {
display: none !important;
}
/* Ensure the host frame doesn't try to use flex-gap on hidden items */
ytd-live-chat-frame { overflow: hidden !important;
height: auto ;
min-height: 100% !important;
max-height: none !important;}
/* Hide ALL native YouTube chat elements when we are snapped in */
ytd-live-chat-frame #show-hide-button,
ytd-live-chat-frame #header,
ytd-live-chat-frame #items,
ytd-live-chat-frame #input-panel,
ytd-live-chat-frame #action-panel,
ytd-live-chat-frame #ticker {
display: none !important;
visibility: hidden !important;
height: 0 !important;
}
/* 2. Ghost the native iframe to prevent visibility crashes but keep it in layout */
ytd-live-chat-frame iframe {
opacity: 0.001 !important;
pointer-events: none !important;
}
/* Hide native chat messages when Terminal is snapped in */
ytd-live-chat-frame #items {
display: none !important;
}
/* Hide the native input area and footer to reclaim space */
ytd-live-chat-frame #input-panel,
ytd-live-chat-frame #action-panel {
display: none !important;
}
#my-term-container, #user-menu {
--bg-l: 0%;
--name-c: #00ff00;
--msg-c: #9A95A7;
--bg-base: hsl(0, 0%, var(--bg-l));
--bg-up: hsl(0, 0%, calc(var(--bg-l) + 10%));
--border-c: hsl(0, 0%, calc(var(--bg-l) + 20%));
}
#my-term-container {
background: var(--bg-base) !important;
border: 1px solid var(--border-c) !important;
border-radius: 4px;
display: flex !important;
flex-direction: column;
font-family: 'Consolas', monospace;
z-index: 2147483647 !important;
box-sizing: border-box;
min-width: 280px; min-height: 200px;
overflow: hidden !important;
flex: none !important;
resize: vertical;
color-scheme: dark !important;
color: #00ff00 !important;
flex: 1 1 auto !important;
}
#term-header { padding: 4px 6px; background: var(--bg-up); color: var(--name-c); border-bottom: 1px solid var(--border-c); font-size: 10px; display: flex; justify-content: space-between; align-items: center; cursor: default; user-select: none; flex-shrink: 0; }
#term-stream {
flex-grow: 1; overflow-y: auto; padding: 10px; color: var(--msg-c); line-height: 1.4;
scrollbar-width: thin; scrollbar-color: var(--border-c) var(--bg-base);
overscroll-behavior: contain !important;
/* Crucial Fix: prevents flex children from expanding parent */
min-height: 0;
}
.term-msg {
display: block;
color: var(--msg-c);
margin-bottom: 3px;
padding-left: 3px;
border-left: 2px solid transparent;
contain: layout; /* Prevents layout leaking */
overflow-anchor: none; /* Stops Firefox from fighting our scroll logic */
will-change: transform; /* Moves rendering to the GPU sparingly */
}
#term-emoji-drawer {
position: absolute; bottom: 100%; right: 5px;
background: var(--bg-up); border: 1px solid var(--border-c);
display: none; grid-template-columns: repeat(6, 1fr); gap: 5px;
padding: 8px; border-radius: 4px; z-index: 2147483647;
box-shadow: 0 -4px 10px rgba(0,0,0,0.5);
// display: none !important;
}
.e-pick { display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
width: 30px;
cursor: pointer;
border: 1px solid transparent;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: transform 0.1s; }
.e-pick:hover { transform: scale(1.2); }
#g-emoji-btn {
cursor: pointer;
font-size: 20px !important; /* Force size */
margin: 0 8px 6px 8px;
filter: grayscale(1) opacity(0.5);
user-select: none;
flex: 0 0 auto !important; /* DO NOT SHRINK */
line-height: 1;
}
#term-anchor {
overflow-anchor: auto;
height: 1px;
width: 100%;
}
.t-auth { color: var(--name-c); cursor: pointer; font-weight: bold; margin-right: 4px; filter: brightness(1.2); }
.t-auth:hover { text-decoration: underline; }
.t-time { color: gray; margin-right: 6px; font-size: 0.9em; display: none; }
.t-hide { display: none !important; }
.t-highlight { background: rgba(255, 50, 50, 0.2); border-left: 2px solid #ff4444; }
.t-super { color: #ffd700 !important; font-weight: bold; }
.t-super-tag { background: #b8860b; color: #fff; padding: 0 4px; margin-right: 5px; border-radius: 2px; }
#term-overlay { position: absolute; top: 28px; left: 0; right: 0; bottom: 0; background: var(--bg-base); color: var(--name-c); z-index: 2147483645; display: none; overflow-y: auto; padding: 15px; font-size: 16px; overscroll-behavior: contain; }
.overlay-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid var(--border-c); padding-bottom: 5px; }
.overlay-item { padding: 6px 10px; border-bottom: 1px solid var(--border-c); display: flex; justify-content: space-between; align-items: center; }
.h-btn { background: var(--bg-up); color: var(--name-c); border: 1px solid var(--border-c); font-size: 10px; cursor: pointer; padding: 1px 4px; }
#user-menu { position: fixed; background: var(--bg-base); border: 1px solid var(--name-c); color: var(--name-c); z-index: 2147483647; display: none; font-size: 11px; min-width: 110px; box-shadow: 4px 4px 0 rgba(0,0,0,0.5); }
.menu-opt { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border-c); }
.menu-opt:hover { background: var(--name-c); color: var(--bg-base); }
#term-input-area { background: var(--bg-up); padding: 5px; border-top: 1px solid var(--border-c); display: flex; flex-shrink: 0; }
#term-input {
background: var(--bg-base); color: var(--msg-c); border: 1px solid var(--border-c);
width: 100%; font-family: 'Consolas', monospace; resize: none; outline: none;
padding: 6px; font-size: 13px; box-sizing: border-box;
min-height: 28px; max-height: 150px; overflow-y: auto; line-height: 1.3;
}
.e-dot { color: var(--msg-c); opacity: 0.6; }
`;
document.head.appendChild(style);
};
const renderEmojiDrawer = (cont) => {
const drawer = cont.querySelector('#term-emoji-drawer');
const chatInput = cont.querySelector('#term-input');
if (!drawer || !chatInput) return;
const emojiBtn = cont.querySelector('#g-emoji-btn');
const stream = cont.querySelector('#term-stream');
drawer.replaceChildren();
emojiList.forEach((emoji, index) => {
const span = document.createElement('span');
span.className = 'e-pick';
const isEmpty = !emoji || emoji === "·" || emoji === " ";
span.textContent = isEmpty ? "·" : emoji;
if (isEmpty) span.style.opacity = "0.3";
span.onclick = (e) => {
e.stopPropagation();
if (!isEmpty) {
chatInput.value += span.textContent;
chatInput.dispatchEvent(new Event('input'));
}
chatInput.focus();
};
span.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
const val = chatInput.value.trim();
const newEmoji = Array.from(val).pop(); // Get last char
if (newEmoji) {
emojiList[index] = newEmoji;
chatInput.value = ""; // Clear box
} else {
emojiList[index] = "·"; // Reset to dot
}
localStorage.setItem('yt-terminal-emojis', JSON.stringify(emojiList));
renderEmojiDrawer(cont); // Destructive Refresh
};
drawer.appendChild(span);
});
};
const setupEvents = (cont) => {
if (cont.dataset.init === "true") return;
// --- DEFINE LOCAL VARIABLES ---
const drawer = cont.querySelector('#term-emoji-drawer');
const emojiBtn = cont.querySelector('#g-emoji-btn');
const chatInput = cont.querySelector('#term-input');
const stream = cont.querySelector('#term-stream');
const overlay = cont.querySelector('#term-overlay');
const filterInput = cont.querySelector('#term-filter');
let currentView = "users";
renderEmojiDrawer(cont);
emojiBtn.onclick = (e) => {
e.stopPropagation();
const isVisible = drawer.style.display === 'grid';
drawer.style.display = isVisible ? 'none' : 'grid';
};
stream.onclick = (e) => {
if (e.ctrlKey && (e.target.classList.contains('e-dot') || e.target.tagName === 'IMG')) {
e.preventDefault();
const captured = e.target.getAttribute('data-emoji') || e.target.getAttribute('alt');
if (!captured) return;
const slot = emojiList.findIndex(icon => icon === "·" || icon === " " || !icon);
if (slot !== -1 && captured) {
emojiList[slot] = captured;
localStorage.setItem('yt-terminal-emojis', JSON.stringify(emojiList));
renderEmojiDrawer(cont); // Redraw
e.target.style.color = 'var(--name-c)';
setTimeout(() => { e.target.style.color = ''; }, 500);
}
}
};
const snapBtn = cont.querySelector('#g-snap');
if (snapBtn) {
snapBtn.onclick = () => {
const target = document.querySelector('ytd-live-chat-frame');
if (target) {
isResizing = true;
savedGeom.isSnapped = true;
localStorage.setItem('yt-terminal-geom', JSON.stringify(savedGeom));
// Reset styles to fill the sidebar
Object.assign(cont.style, {
position: 'absolute',
top: '0', left: '0', right: '0', bottom: 'auto',
width: '100%', height: savedGeom.snappedHeight || '100%',
margin: '0', resize: 'vertical',overflow: 'hidden'
});
// Physically move the element back to the YouTube sidebar
target.appendChild(cont);
setTimeout(() => {
if (isLockedToBottom) scrollToBottom();
isResizing = false;
}, 150);
}
};
}
cont.addEventListener('click', (e) => {
if (!drawer.contains(e.target) && e.target !== emojiBtn) {
drawer.style.display = 'none';
}
});
// Close drawer if clicking anywhere else in the terminal
cont.addEventListener('click', () => { drawer.style.display = 'none'; });
const applyTheme = () => {
const menu = document.getElementById('user-menu');
const bgStr = (savedSizes.bgLight || 0) + '%';
cont.style.setProperty('--bg-l', bgStr);
cont.style.setProperty('--name-c', savedSizes.nameColor || '#00ff00');
cont.style.setProperty('--msg-c', savedSizes.msgColor || '#eeeeee');
if (menu) {
menu.style.setProperty('--bg-l', bgStr);
menu.style.setProperty('--name-c', savedSizes.nameColor || '#00ff00');
menu.style.setProperty('--msg-c', savedSizes.msgColor || '#eeeeee');
}
};
applyTheme();
cont.querySelector('#f-size').value = savedSizes.font;
const streamContainer = cont.querySelector('#term-stream');
streamContainer.addEventListener('scroll', () => {
// If the scroll destination matches our exact programmatic target (allowing 2px for browser zoom rounding), it was us. Ignore it.
if (expectedScrollTop !== -1 && Math.abs(streamContainer.scrollTop - expectedScrollTop) <= 2) {
expectedScrollTop = -1;
return;
}
// Otherwise, it was a user-initiated scroll.
if (!isTicking) {
isTicking = true;
window.requestAnimationFrame(() => {
if (isResizing) {
isTicking = false;
return;
}
if (streamContainer.clientHeight > 0) {
const distanceToBottom = streamContainer.scrollHeight - streamContainer.scrollTop - streamContainer.clientHeight;
isLockedToBottom = distanceToBottom <= 15; // Tightened threshold for snapping
if (!isLockedToBottom && !isPaused) {
isPaused = true;
const pauseBtn = document.getElementById('g-pause');
if (pauseBtn) {
pauseBtn.textContent = '▶';
pauseBtn.style.color = '#f44';
pauseBtn.style.borderColor = '#f44';
}
}
else if (isLockedToBottom && isPaused) {
isPaused = false;
const pauseBtn = document.getElementById('g-pause');
if (pauseBtn) {
pauseBtn.textContent = '⏸';
pauseBtn.style.color = 'var(--name-c)';
pauseBtn.style.borderColor = 'var(--border-c)';
}
if (messageBuffer.length > 0) {
const fragment = document.createDocumentFragment();
messageBuffer.forEach(msgDiv => fragment.appendChild(msgDiv));
const anchor = document.getElementById('term-anchor');
streamContainer.insertBefore(fragment, anchor);
messageBuffer = [];
const limit = parseInt(savedSizes.msgLimit) || 500;
while (streamContainer.childElementCount > limit) {
streamContainer.firstElementChild.remove();
}
scrollToBottom();
}
}
}
isTicking = false;
});
}
}, { passive: true });
cont.querySelector('#e-size').value = savedSizes.emo;
chatInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const text = this.value.trim();
// Intercept the /clear command
if (text === '/clear') {
const stream = document.getElementById('term-stream');
const anchor = document.getElementById('term-anchor');
if (stream && anchor) {
stream.textContent = ''; // Safely empties the node without triggering TrustedHTML CSP
stream.appendChild(anchor);
}
messageBuffer = [];
this.value = "";
this.style.height = 'auto';
return;
}
if (text) {
sendToNative(text);
cmdHistory.push(text);
if (cmdHistory.length > 50) cmdHistory.shift();
localStorage.setItem('yt-terminal-history', JSON.stringify(cmdHistory));
cmdIndex = cmdHistory.length;
this.value = "";
this.style.height = 'auto';
}
}
else if (e.key === 'ArrowUp') {
if (cmdIndex > 0) {
cmdIndex--;
this.value = cmdHistory[cmdIndex];
setTimeout(() => { this.selectionStart = this.selectionEnd = this.value.length; }, 0);
this.dispatchEvent(new Event('input'));
}
e.preventDefault();
}
else if (e.key === 'ArrowDown') {
if (cmdIndex < cmdHistory.length - 1) {
cmdIndex++;
this.value = cmdHistory[cmdIndex];
this.dispatchEvent(new Event('input'));
} else if (cmdIndex === cmdHistory.length - 1) {
cmdIndex++;
this.value = "";
this.style.height = 'auto';
}
e.preventDefault();
}
});
cont.querySelector('#g-pause').onclick = (e) => {
isPaused = !isPaused;
e.target.textContent = isPaused ? '▶' : '⏸';
e.target.style.color = isPaused ? '#f44' : 'var(--name-c)';
e.target.style.borderColor = isPaused ? '#f44' : 'var(--border-c)';
if (!isPaused) {
isLockedToBottom = true; // 1. Re-engage the lock mathematically
if (messageBuffer.length > 0) {
const streamContainer = document.getElementById('term-stream');
const fragment = document.createDocumentFragment();
messageBuffer.forEach(msgDiv => fragment.appendChild(msgDiv));
const anchor = document.getElementById('term-anchor');
streamContainer.insertBefore(fragment, anchor);
messageBuffer = [];
const limit = parseInt(savedSizes.msgLimit) || 500;
while (streamContainer.childElementCount > limit) {
streamContainer.firstChild.remove();
}
}
scrollToBottom(); // 2. Force scroll unconditionally
}
};
new ResizeObserver(entries => {
for (let entry of entries) {
if (isResizing) continue;
if (savedGeom.isSnapped) {
const target = document.querySelector('ytd-live-chat-frame');
if (target) {
// We must set both height AND minHeight to override YouTube's internal CSS
const h = entry.contentRect.height + 'px';
target.style.setProperty('height', h, 'important');
target.style.setProperty('min-height', h, 'important');
savedGeom.snappedHeight = h;
}
}
// Save to memory
localStorage.setItem('yt-terminal-geom', JSON.stringify(savedGeom));
}
}).observe(cont);
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' || e.keyCode === 27) {
let intercepted = false;
if (filterInput.value || chatInput.value) {
filterInput.value = ""; filterTerm = ""; chatInput.value = "";
chatInput.style.height = 'auto';
cont.querySelectorAll('.term-msg').forEach(m => m.classList.remove('t-hide'));
intercepted = true;
scrollToBottom();
}
if (overlay.style.display === 'block') {
overlay.style.display = 'none';
intercepted = true;
}
const menu = document.getElementById('user-menu');
if (menu && menu.style.display === 'block') {
menu.style.display = 'none';
intercepted = true;
}
if (document.activeElement === filterInput || document.activeElement === chatInput) {
filterInput.blur();
chatInput.blur();
intercepted = true;
}
if (intercepted) {
e.preventDefault();
e.stopImmediatePropagation();
}
}
}, true);
const sanitizeGeom = () => {
let t = parseInt(savedGeom.top) || 100;
let l = parseInt(savedGeom.left) || 100;
if (t < 0 || t > window.innerHeight - 50) t = 100;
if (l < 0 || l > window.innerWidth - 100) l = 100;
savedGeom.top = t + 'px';
savedGeom.left = l + 'px';
};
cont.querySelector('#g-unsnap').onclick = () => {
isResizing = true; // Shield against rogue layout-shift scroll events
sanitizeGeom();
savedGeom.isSnapped = false;
localStorage.setItem('yt-terminal-geom', JSON.stringify(savedGeom));
const floatW = (savedGeom.width && savedGeom.width !== '100%') ? savedGeom.width : '450px';
const floatH = (savedGeom.height && savedGeom.height !== '100%') ? savedGeom.height : '600px';
cont.style.display = 'none' ;
Object.assign(cont.style, {
position: 'fixed',
top: savedGeom.top || '100px',
left: savedGeom.left || '100px',
width: floatW, // Explicit pixels
height: floatH, // Explicit pixels
right: 'auto',
bottom: 'auto',
margin: '0',
resize: 'both',
zIndex: '2147483647' // Keep it on top
});
document.body.appendChild(cont);
setTimeout(() => {
isResizing = false;
}, 200);
};
let isDragging = false, offset = [0,0];
cont.addEventListener('mousedown', (e) => {
if (e.altKey && !savedGeom.isSnapped) {
e.preventDefault();
isDragging = true;
offset = [cont.offsetLeft - e.clientX, cont.offsetTop - e.clientY];
cont.style.cursor = 'grabbing';
}
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
let newLeft = (e.clientX + offset[0]) + 'px';
let newTop = (e.clientY + offset[1]) + 'px';
cont.style.left = newLeft;
cont.style.top = newTop;
savedGeom.left = newLeft;
savedGeom.top = newTop;
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
cont.style.cursor = 'auto';
sanitizeGeom();
localStorage.setItem('yt-terminal-geom', JSON.stringify(savedGeom));
}
});
cont.addEventListener('click', (e) => {
const menu = document.getElementById('user-menu');
if (e.target.classList.contains('t-auth')) {
const user = e.target.textContent.replace(':', '').trim();
const cleanUser = user.replace(/^@+/, '');
const userData = activeParticipants.get(user) || {};
menu.innerHTML = policy.createHTML(`
<div class="menu-opt" id="m-mention">@MENTION</div>
<div class="menu-opt" id="m-mute">MUTE</div>
<div class="menu-opt" id="m-visit">VISIT</div>
<div class="menu-opt" id="m-cancel" style="color:var(--msg-c); opacity:0.6;">CANCEL</div>
`);
applyTheme();
menu.style.display = 'block';
menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px';
document.getElementById('m-mention').onclick = () => { chatInput.value += `@${cleanUser} `; chatInput.focus(); chatInput.dispatchEvent(new Event('input')); menu.style.display = 'none'; };
document.getElementById('m-mute').onclick = () => { mutedUsers.add(user); saveMuted(); purgeMuted(user); menu.style.display = 'none'; };
document.getElementById('m-visit').onclick = () => {
let finalUrl = "";
if (userData.cid && userData.cid.startsWith('UC')) { finalUrl = `https://www.youtube.com/channel/${userData.cid}`; }
else if (userData.url) { finalUrl = userData.url; }
else { const handle = user.startsWith('@') ? user : `@${user}`; finalUrl = `https://www.youtube.com/${handle.replace(':', '').trim()}`; }
if (finalUrl) window.open(finalUrl, '_blank');
menu.style.display = 'none';
};
document.getElementById('m-cancel').onclick = () => { menu.style.display = 'none'; };
} else { if (menu) menu.style.display = 'none'; }
});
const renderOverlay = () => {
if (currentView === "users") {
const active = Array.from(activeParticipants.keys()).filter(p => !mutedUsers.has(p)).sort();
overlay.innerHTML = policy.createHTML(`
<div class="overlay-header"><strong>ACTIVE (${active.length})</strong> <button id="o-toggle-muted" class="h-btn" style="color:#f44; border-color:#f44;">MUTED LIST</button></div>
${active.map(p => `<div class="overlay-item"><span>${p}</span> <button class="h-btn o-mute" data-user="${p}">MUTE</button></div>`).join('')}
`);
} else if (currentView === "muted") {
const muted = Array.from(mutedUsers).sort();
overlay.innerHTML = policy.createHTML(`
<div class="overlay-header">
<strong>MUTED (${muted.length})</strong>
<div>
<button id="o-unmute-all" class="h-btn" style="color:#f44; margin-right:6px; border-color:#f44;">UNMUTE ALL</button>
<button id="o-toggle-users" class="h-btn">ACTIVE USERS</button>
</div>
</div>
${muted.map(p => `<div class="overlay-item"><span>${p}</span> <button class="h-btn o-unmute" data-user="${p}">UNMUTE</button></div>`).join('')}
`);
} else if (currentView === "help") {
overlay.innerHTML = policy.createHTML(`
<div class="overlay-header">
<strong>TERMINAL COMMANDS</strong>
<div>
<button id="o-dump" class="h-btn" style="color:#0f0; border-color:#0f0; margin-right:6px;">DUMP LOG</button>
<button id="o-close" class="h-btn" style="color:#f44; border-color:#f44;">CLOSE</button>
</div>
</div>
<div style="line-height:1.6;">
<strong>ENTER:</strong> Send comment<br>
<strong>SHIFT+ENTER:</strong> Newline<br>
<strong>DISPLAY:</strong> Text Size (10,13,15) | Icon Size (15,20,●,0)<br>
<strong>FILTER:</strong> Search comments<br>
<strong>▲▼:</strong> Unsnap/Snap<br>
<strong>👤:</strong> User list<br>
<strong>ALT+DRAG:</strong> Move window | <strong>RESIZE:</strong> Drag corner<br>
<strong>ESC:</strong> Clear text & menus | <strong>CLOCK:</strong> Toggle time<br>
<strong>CHAT:</strong> Up/Down Arrow for History<br>
<hr style="border:0; border-top:1px solid var(--border-c); margin:8px 0;">
<strong>MSG LIMIT:</strong> <input type="number" id="c-limit" value="${savedSizes.msgLimit || 500}" style="background:var(--bg-base); color:var(--msg-c); border:1px solid var(--border-c); width:50px; font-size:11px; padding:2px; margin-bottom:5px;"><br>
<strong>HIGHLIGHT WORDS:</strong> <input type="text" id="c-high" value="${savedSizes.highlights || ''}" placeholder="myname, topic..." style="background:var(--bg-base); color:var(--msg-c); border:1px solid var(--border-c); width:130px; font-size:11px; padding:2px;">
<div style="font-size:9px; color:gray; line-height:1; margin-bottom:5px; margin-top:2px;">(Separate multiple words with commas)</div>
<strong>BACKGROUND:</strong> <input type="range" id="c-range" min="0" max="100" value="${savedSizes.bgLight || 0}" style="vertical-align:middle; width:80px;"><br>
<div style="margin-top:5px;">
<strong>NAME:</strong> <input type="color" id="c-name" value="${savedSizes.nameColor || '#55A34D'}" style="vertical-align:middle; width:25px; height:20px; padding:0; border:1px solid var(--border-c); background:var(--bg-base); cursor:pointer; margin-right:15px;">
<strong>MSG:</strong> <input type="color" id="c-msg" value="${savedSizes.msgColor || '#eeeeee'}" style="vertical-align:middle; width:25px; height:20px; padding:0; border:1px solid var(--border-c); background:var(--bg-base); cursor:pointer;">
</div>
</div>
`);
const range = overlay.querySelector('#c-range');
if (range) {
range.oninput = (v) => { savedSizes.bgLight = v.target.value; applyTheme(); };
range.onchange = () => { localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); };
}
const pickerName = overlay.querySelector('#c-name');
if (pickerName) {
pickerName.oninput = (v) => { savedSizes.nameColor = v.target.value; applyTheme(); };
pickerName.onchange = () => { localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); };
}
const pickerMsg = overlay.querySelector('#c-msg');
if (pickerMsg) {
pickerMsg.oninput = (v) => { savedSizes.msgColor = v.target.value; applyTheme(); };
pickerMsg.onchange = () => { localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); };
}
const inputHigh = overlay.querySelector('#c-high');
if (inputHigh) {
inputHigh.oninput = (e) => { savedSizes.highlights = e.target.value; localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); };
}
const inputLimit = overlay.querySelector('#c-limit');
if (inputLimit) {
inputLimit.onchange = (e) => { savedSizes.msgLimit = parseInt(e.target.value) || 500; localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); };
}
}
};
overlay.onclick = (e) => {
if (e.target.id === 'o-toggle-muted') { currentView = "muted"; renderOverlay(); }
else if (e.target.id === 'o-toggle-users') { currentView = "users"; renderOverlay(); }
else if (e.target.id === 'o-unmute-all') { mutedUsers.clear(); saveMuted(); renderOverlay(); }
else if (e.target.id === 'o-close') { overlay.style.display = 'none'; }
else if (e.target.id === 'o-dump') {
const msgs = Array.from(cont.querySelectorAll('.term-msg')).map(m => {
const time = m.querySelector('.t-time')?.textContent || '';
const auth = m.querySelector('.t-auth')?.textContent || '';
const text = m.querySelector('span:last-child')?.textContent || '';
return `[${time}] ${auth} ${text}`;
}).join('\n');
const blob = new Blob([msgs], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `Terminal_Chat_Log_${new Date().toISOString().slice(0,10)}.txt`;
a.click();
}
else if (e.target.classList.contains('o-mute')) {
const u = e.target.dataset.user; mutedUsers.add(u); saveMuted(); purgeMuted(u); renderOverlay();
}
else if (e.target.classList.contains('o-unmute')) {
const u = e.target.dataset.user; mutedUsers.delete(u); saveMuted(); renderOverlay();
}
};
cont.querySelector('#g-user').onclick = () => {
currentView = "users";
overlay.style.display = overlay.style.display === 'block' ? 'none' : 'block';
if (overlay.style.display === 'block') renderOverlay();
};
cont.querySelector('#g-help').onclick = () => {
currentView = "help";
overlay.style.display = overlay.style.display === 'block' ? 'none' : 'block';
if (overlay.style.display === 'block') renderOverlay();
};
cont.querySelector('#f-size').onchange = (e) => { savedSizes.font = e.target.value; cont.querySelectorAll('.term-msg').forEach(syncStyle); localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); scrollToBottom(); };
cont.querySelector('#e-size').onchange = (e) => { savedSizes.emo = e.target.value; cont.querySelectorAll('.term-msg').forEach(syncStyle); localStorage.setItem('yt-terminal-sizes', JSON.stringify(savedSizes)); scrollToBottom(); };
cont.querySelector('#term-clock').onclick = () => {
showTimestamps = !showTimestamps;
cont.querySelectorAll('.term-msg').forEach(syncStyle);
if (isLockedToBottom) scrollToBottom();
};
filterInput.oninput = (e) => {
filterTerm = e.target.value.toLowerCase();
cont.querySelectorAll('.term-msg').forEach(m => {
if (!m.classList.contains('session-break')) {
m.classList.toggle('t-hide', !m.textContent.toLowerCase().includes(filterTerm));
}
});
};
cont.dataset.init = "true";
};
const enforceTop = () => {
const cont = document.getElementById('my-term-container');
const target = document.querySelector('ytd-live-chat-frame');
if (!savedGeom.isSnapped) return;
// Only force the position if the user wants it snapped
if (cont && target && savedGeom.isSnapped) {
if (cont.parentElement !== target) {
target.appendChild(cont);
}
// --- NATIVE GEOMETRY ENFORCEMENT ---
if (savedGeom.snappedHeight) {
target.style.height = savedGeom.snappedHeight;
target.style.minHeight = savedGeom.snappedHeight;
}
}
};
const injectChannelHeader = () => {
const channelLink = document.querySelector('#upload-info #channel-name a, .ytd-video-owner-renderer #channel-name a, #owner-name a');
const channelName = channelLink?.textContent.trim();
if (channelName && channelName !== currentChannel) {
currentChannel = channelName;
const stream = document.getElementById('term-stream');
if (stream) {
const hr = document.createElement('div');
hr.className = "session-break term-msg";
hr.style.cssText = "color: var(--msg-c); border-bottom: 1px dashed var(--border-c); margin: 5px 0 10px 0; padding-bottom: 5px; text-align: center; font-size: 11px; letter-spacing: 1px; border-left:none;";
hr.textContent = `*** CONNECTED TO: ${currentChannel.toUpperCase()} ***`;
const anchor = document.getElementById('term-anchor');
if (anchor) {
stream.insertBefore(hr, anchor);
} else {
stream.appendChild(hr);
}
if (isLockedToBottom) {
scrollToBottom();
}
}
}
};
const createUI = () => {
if (document.getElementById('my-term-container')) return;
const cont = document.createElement('div');
cont.id = 'my-term-container';
// Eclipsing directly over the native wrapper
const target = document.querySelector('ytd-live-chat-frame');
if (!target) return;
applyStyles();
if (!document.getElementById('user-menu')) { const m = document.createElement('div'); m.id = 'user-menu'; document.body.appendChild(m); }
cont.innerHTML = policy.createHTML(`
<div id="term-header">
<div style="display:flex; gap:4px; align-items:center;">
<input type="text" id="term-filter" placeholder="FLTR" class="h-btn" style="width:35px;">
<select id="f-size" class="h-btn"><option value="10px">10</option><option value="13px">13</option><option value="15px">15</option></select>
<select id="e-size" class="h-btn"><option value="15px">15</option><option value="20px">20</option><option value="dot">●</option><option value="0px">00</option></select>
<button id="g-unsnap" class="h-btn">▲</button><button id="g-snap" class="h-btn">▼</button>
<span id="term-debug" style="font-size:9px; color:gray; margin-left:5px;">...</span>
</div>
<div style="display:flex; gap:6px; align-items:center;">
<button id="g-pause" class="h-btn" style="font-size:11px; padding:0 4px;">⏸</button>
<span id="term-clock" style="cursor:pointer; font-size:12px;">00:00:00</span>
<button id="g-user" class="h-btn">👤</button>
<button id="g-help" class="h-btn">?</button>
</div>
</div>
<div id="term-overlay"></div>
<div id="term-stream">
<div id="term-anchor"></div>
</div>
<div id="term-input-area" style="position:relative; display:flex; align-items:flex-end;">
<textarea id="term-input" rows="1" placeholder="Chat..."></textarea>
<div id="term-emoji-drawer"></div>
<span id="g-emoji-btn">😀</span></div>
</div>
`);
if (savedGeom.isSnapped) {
Object.assign(cont.style, {
position: 'absolute',
top: '0',
left: '0',
right: '0',
bottom: 'auto', // MUST be auto to allow the height to change
width: '100%',
height: savedGeom.snappedHeight || '600px', // Use a default pixel height if 100%
margin: '0',
resize: 'vertical',
display: 'flex',
flexDirection: 'column'
});
document.body.appendChild(cont);
}
setupEvents(cont);
setInterval(() => { const c = document.getElementById('term-clock'); if(c) c.textContent = new Date().toLocaleTimeString('en-GB'); }, 1000);
};
const watch = () => {
injectChannelHeader();
const dbg = document.getElementById('term-debug');
const frame = document.querySelector('ytd-live-chat-frame iframe');
if (!frame || !dbg) return;
let chatDoc;
try {
chatDoc = frame.contentDocument || frame.contentWindow.document;
} catch (e) {
dbg.textContent = "SHIELD";
return;
}
const items = chatDoc?.getElementById('items');
if (items && items !== lastItems) {
dbg.textContent = "LINK";
lastItems = items;
activeObserver?.disconnect();
let processingTimeout = null;
const processQueue = () => {
const stream = document.getElementById('term-stream');
if (!stream) return;
const highWords = (savedSizes.highlights || "").split(',').map(s => s.trim().toLowerCase()).filter(s => s);
const limit = parseInt(savedSizes.msgLimit) || 500;
const msgs = items.querySelectorAll('yt-live-chat-text-message-renderer:not([data-cap]), yt-live-chat-paid-message-renderer:not([data-cap])');
if (msgs.length > 0) {
const fragment = document.createDocumentFragment();
let addedCount = 0;
msgs.forEach(msg => {
msg.dataset.cap = 'true';
const auth = msg.querySelector('#author-name')?.textContent || 'User';
// Repopulate user map safely. If Polymer throws a DOM error, catch it and keep the chat alive.
if (auth !== 'User') {
let cid = '';
try { cid = msg.getAttribute('author-external-channel-id') || ''; } catch (e) {}
activeParticipants.set(auth, { cid: cid, lastSeen: Date.now() });
}
if (mutedUsers.has(auth)) return;
const div = document.createElement('div');
div.className = 'term-msg';
const isSuper = msg.tagName.toLowerCase() === 'yt-live-chat-paid-message-renderer';
const msgText = msg.querySelector('#message')?.textContent || '';
let isHigh = false;
if (highWords.length > 0) {
const fullText = (auth + " " + msgText).toLowerCase();
isHigh = highWords.some(w => fullText.includes(w));
}
if (isSuper) div.classList.add('t-super');
if (isHigh) div.classList.add('t-highlight');
const rawHtml = msg.querySelector('#message')?.innerHTML || '';
const msgHtml = rawHtml.replace(/<img[^>]*alt="([^"]+)"[^>]*>/gi, (m, alt) => {
// We store the emoji character in data-emoji for the Ctrl-click listener
return `${m}<span class="e-dot" data-emoji="${alt}" style="display:none; margin:0 2px; cursor:pointer;">●</span>`;});
const amount = isSuper ? (msg.querySelector('#purchase-amount')?.textContent || 'SUPER') : '';
const prefix = isSuper ? `<span class="t-super-tag">[${amount}]</span> ` : '';
const time = (msg.querySelector('#timestamp')?.textContent || '').replace(/\s*[ap]m/gi, '').trim();
div.innerHTML = policy.createHTML(`<span class="t-time">${time}</span><strong class="t-auth">${auth}:</strong> <span>${prefix}${msgHtml}</span>`);
syncStyle(div);
if (isPaused) {
messageBuffer.push(div);
if (messageBuffer.length > limit) messageBuffer.shift();
} else {
fragment.appendChild(div);
addedCount++;
}
});
if (addedCount > 0 && !isPaused) {
isAutoScrolling = true;
const anchor = document.getElementById('term-anchor');
if (anchor) {
stream.insertBefore(fragment, anchor);
} else {
stream.appendChild(fragment);
}
let overage = stream.childElementCount - limit;
if (overage > 0) {
for (let i = 0; i < overage; i++) {
stream.firstElementChild.remove();
}
}
if (isLockedToBottom) {
scrollToBottom();
} else {
setTimeout(() => { isAutoScrolling = false; }, 50);
}
}
}
};
let isProcessingScheduled = false;
activeObserver = new MutationObserver(() => {
// If a flush is already scheduled, ignore new mutations until it fires
if (!isProcessingScheduled) {
isProcessingScheduled = true;
// Batch and process the queue at a maximum speed of 20 frames per second
window.requestAnimationFrame(() => {
processQueue();
isProcessingScheduled = false;
}, 50);
}
});
activeObserver.observe(items, { childList: true });
// Aggressively sweep up any messages that loaded before our observer attached
processQueue();
}
};
const runLoop = () => {
const target = document.querySelector('ytd-live-chat-frame');
const term = document.getElementById('my-term-container');
// 1. IF THE CHAT EXISTS
if (target) {
// If terminal is totally missing, build it
if (!term) {
createUI();
setupEvents(document.getElementById('my-term-container'));
}
// If terminal exists but YouTube moved it, snap it back
else if (term.parentElement !== target && savedGeom.isSnapped) {
target.appendChild(term);
term.style.display = 'flex';
}
// Sync the messages and check positioning
watch();
enforceTop();
// Update Debug Text if it exists
const dbg = document.getElementById('term-debug');
if (dbg) dbg.textContent = (lastItems) ? "LINK" : "SYNCING...";
}
// 2. IF YOU LEFT THE VIDEO/CHAT PAGE
else if (term) {
term.style.display = 'none'; // Hide it until you find a new chat
}
// 3. SET THE GEARS
// Fast (200ms) if we are looking for a home, Slow (1000ms) if we are linked.
const isLinked = target && term && lastItems;
const nextTick = isLinked ? 1000 : 200;
setTimeout(runLoop, nextTick);
};
// Start the engine
runLoop();
})();