YouTube Comments Sidebar (Toggle, 2025)

Show comments in the right sidebar with a Comments/Recommended toggle on YouTube watch pages.

// ==UserScript==
// @name         YouTube Comments Sidebar (Toggle, 2025)
// @namespace    yt-comments-sidebar
// @version      1.3
// @description  Show comments in the right sidebar with a Comments/Recommended toggle on YouTube watch pages.
// @match        https://www.youtube.com/*
// @run-at       document-start
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
  "use strict";

  const IS_WATCH = () => location.pathname === "/watch" && /[?&]v=/.test(location.search);
  const QS = sel => document.querySelector(sel);

  const SELECTORS = {
    flexy: "ytd-watch-flexy",
    secondary: "ytd-watch-flexy #secondary",
    comments: "ytd-watch-flexy ytd-comments#comments, ytd-watch-flexy #comments",
  };

  const STYLE_ID = "tm-ycs-styles";
  const WRAP_ID = "tm-ycs-wrap";
  const TABS_ID = "tm-ycs-tabs";
  const UP_NEXT_ID = "tm-ycs-upnext";
  const COMMENTS_WRAP_ID = "tm-ycs-comments";

  function injectStyles() {
    if (QS(`#${STYLE_ID}`)) return;
    const s = document.createElement("style");
    s.id = STYLE_ID;
    s.textContent = `
      #${TABS_ID} {
        display:flex; gap:.5rem; align-items:center;
        position:sticky; top:56px; z-index:5;
        padding:.5rem .75rem; border-bottom:1px solid rgba(255,255,255,.08);
        background: rgba(18,18,18,.98);
        backdrop-filter: saturate(120%) blur(6px);
      }
      #${TABS_ID} button{
        font: 500 13px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Arial;
        padding:.4rem .7rem; border-radius:999px; border:1px solid transparent; cursor:pointer;
        background:#272727; color:#eee;
      }
      #${TABS_ID} button.active{ background:#3a3a3a; border-color:#4a4a4a; }
      #${COMMENTS_WRAP_ID}, #${UP_NEXT_ID} {
        max-height: calc(100vh - 56px - 44px); /* header + tabs */
        overflow-y: auto; overflow-x: hidden;
      }
      /* Make comments readable in the narrow column */
      #${COMMENTS_WRAP_ID} #content { max-width: 100% !important; }
    `;
    document.documentElement.appendChild(s);
  }

  function waitFor(selector, { timeout = 15000, root = document } = {}) {
    return new Promise((resolve, reject) => {
      const found = root.querySelector(selector);
      if (found) return resolve(found);

      const obs = new MutationObserver(() => {
        const el = root.querySelector(selector);
        if (el) {
          obs.disconnect();
          resolve(el);
        }
      });
      obs.observe(root, { childList: true, subtree: true });

      setTimeout(() => {
        obs.disconnect();
        reject(new Error("Timeout waiting for " + selector));
      }, timeout);
    });
  }

  function buildTabs() {
    const tabs = document.createElement("div");
    tabs.id = TABS_ID;

    const btnComments = document.createElement("button");
    btnComments.textContent = "Comments";
    btnComments.dataset.target = COMMENTS_WRAP_ID;

    const btnUpNext = document.createElement("button");
    btnUpNext.textContent = "Recommended";
    btnUpNext.dataset.target = UP_NEXT_ID;

    tabs.append(btnComments, btnUpNext);

    function activate(targetId) {
      const commentsWrap = QS(`#${COMMENTS_WRAP_ID}`);
      const upnextWrap = QS(`#${UP_NEXT_ID}`);
      if (!commentsWrap || !upnextWrap) return;

      const buttons = tabs.querySelectorAll("button");
      buttons.forEach(b => b.classList.toggle("active", b.dataset.target === targetId));

      commentsWrap.style.display = (targetId === COMMENTS_WRAP_ID) ? "" : "none";
      upnextWrap.style.display     = (targetId === UP_NEXT_ID)     ? "" : "none";
    }

    tabs.addEventListener("click", (e) => {
      const b = e.target.closest("button");
      if (!b) return;
      activate(b.dataset.target);
    });

    // default to Comments first
    queueMicrotask(() => activate(COMMENTS_WRAP_ID));
    return tabs;
  }

  function wrapSecondary(secondary) {
    if (QS(`#${WRAP_ID}`)) return; // already wrapped

    // Move existing children into our UpNext wrapper
    const upNext = document.createElement("div");
    upNext.id = UP_NEXT_ID;

    const frag = document.createDocumentFragment();
    while (secondary.firstChild) frag.appendChild(secondary.firstChild);
    upNext.appendChild(frag);

    const commentsWrap = document.createElement("div");
    commentsWrap.id = COMMENTS_WRAP_ID;

    const container = document.createElement("div");
    container.id = WRAP_ID;

    const tabs = buildTabs();
    container.append(tabs, commentsWrap, upNext);
    secondary.appendChild(container);
  }

  function moveCommentsIntoSidebar() {
    const comments = QS(SELECTORS.comments);
    const slot = QS(`#${COMMENTS_WRAP_ID}`);
    if (!comments || !slot) return false;

    // Only move once
    if (!slot.contains(comments)) {
      slot.appendChild(comments);
    }
    return true;
  }

  function primeCommentsLoad() {
    // If YouTube hasn’t hydrated comments yet, nudge it by briefly scrolling
    // the original position into view (if it exists in DOM).
    const original = QS(SELECTORS.comments);
    if (original) {
      original.scrollIntoView({ block: "center" });
    }
  }

  async function initOnce() {
    if (!IS_WATCH()) return;

    injectStyles();

    const flexy = await waitFor(SELECTORS.flexy).catch(() => null);
    if (!flexy) return;

    const secondary = await waitFor(SELECTORS.secondary, { root: flexy }).catch(() => null);
    if (!secondary) return;

    wrapSecondary(secondary);
    primeCommentsLoad();

    // Keep trying to capture comments as soon as they appear
    let tries = 0;
    const maxTries = 40;
    const interval = setInterval(() => {
      const ok = moveCommentsIntoSidebar();
      tries++;
      if (ok || tries >= maxTries) clearInterval(interval);
    }, 500);
  }

  // Handle SPA navigation and first load
  const boot = () => {
    if (!IS_WATCH()) return;
    initOnce().catch(() => {});
  };

  // YouTube fires this on in-page navigation
  window.addEventListener("yt-navigate-finish", boot);
  // Also run after full load
  window.addEventListener("load", boot);
  // And if URL changes without event (edge cases)
  let lastHref = location.href;
  new MutationObserver(() => {
    if (location.href !== lastHref) {
      lastHref = location.href;
      boot();
    }
  }).observe(document, { childList: true, subtree: true });
})();