Terminal Chat

High-performance terminal-style YouTube live chat interface with custom themes, message pruning, and power-user controls.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();