YouTube Controller

Transcript toggle and 15s/5s back/forward buttons for YouTube watch & shorts pages. Shadow DOM sandboxed; SPA-safe routing.

// ==UserScript==
// @name         YouTube Controller
// @namespace    yt-controller
// @version      2.8.1
// @description  Transcript toggle and 15s/5s back/forward buttons for YouTube watch & shorts pages. Shadow DOM sandboxed; SPA-safe routing.
// @match        *://www.youtube.com/*
// @run-at       document-start
// @all-frames   true
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  // --- config (kept from your v2.7 tweaks)
  const SEEK_5  = 5;
  const SEEK_15 = 15;

  const BTN      = 30;  // px
  const GAP      = 6;   // px
  const RADIUS   = 10;  // px
  const ICON_PX  = 16;  // px
  const OFFSET_X = 15;  // px from logo
  const MIN_TOP  = 8;

  // Route guard: only enable on /watch or /shorts
  const isTargetRoute = () =>
    location.pathname.startsWith('/watch') || location.pathname.startsWith('/shorts');

  // --- load Material Symbols in the page (shadow uses it)
  function ensureMaterialSymbols() {
    if (document.getElementById('ytCtrlMaterialSymbols')) return;
    const link = document.createElement('link');
    link.id = 'ytCtrlMaterialSymbols';
    link.rel = 'stylesheet';
    link.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,600,1,0';
    (document.head || document.documentElement).appendChild(link);
  }

  // --- create shadow host + shadow UI (isolated CSS)
  const HOST_ID = 'ytControllerHost';
  let host, root, bar;
  let btnTranscript, btnBack15, btnBack5, btnFwd5, btnFwd15;
  let mo; // MutationObserver
  let mounted = false;

  function ensureHost() {
    if (host && root && bar) return true;
    if (!document.body) return false;

    host = document.getElementById(HOST_ID);
    if (!host) {
      host = document.createElement('div');
      host.id = HOST_ID;
      host.style.position = 'fixed';
      host.style.left = '16px';
      host.style.top  = '12px';
      host.style.zIndex = '2147483647';
      host.style.pointerEvents = 'auto';
      document.body.appendChild(host);
    }
    if (!root) {
      root = host.attachShadow({ mode: 'open' });
      const style = document.createElement('style');
      style.textContent = `
        :host { all: initial; }
        .bar { display: inline-flex; gap: ${GAP}px; }
        .btn {
          width: ${BTN}px; height: ${BTN}px;
          display: flex;
          align-items: center;
          justify-content: center;
          background: #ff6060; /* your light-mode red */
          color: #fff;
          border: none;
          border-radius: ${RADIUS}px;
          box-shadow: none;
          cursor: pointer;
          outline: none;
          transition: filter .15s ease;
        }
        @media (prefers-color-scheme: dark) {
          .btn { background: #6e5dd1; box-shadow: 0 2px 6px rgba(0,0,0,.28); }
        }
        .btn:hover:not([disabled]) { filter: brightness(1.06) saturate(1.05); }
        .btn:active:not([disabled]) { filter: brightness(.98); }
        .btn[disabled] { opacity: .45; cursor: not-allowed; filter: none; }
        .ms {
          font-family: 'Material Symbols Rounded', sans-serif;
          font-variation-settings: 'FILL' 1, 'wght' 600, 'GRAD' 0, 'opsz' 24;
          font-size: ${ICON_PX}px;
          line-height: 1;
        }
      `;
      root.appendChild(style);

      bar = document.createElement('div');
      bar.className = 'bar';
      root.appendChild(bar);
    }
    return true;
  }

  // --- helpers
  function ms(name) { const s = document.createElement('span'); s.className = 'ms'; s.textContent = name; return s; }

  // SMART video targeting (watch & shorts)
  function getActiveVideo() {
    const path = location.pathname;
    if (path.startsWith('/shorts')) {
      let v = document.querySelector('ytd-reel-video-renderer[is-active] video');
      if (v) return v;
      v = document.querySelector('ytd-reel-video-renderer[tabindex="0"] video, ytd-reel-video-renderer[is-playable] video');
      if (v) return v;
      const candidates = document.querySelectorAll('ytd-shorts video, #shorts-container video, video[playsinline]');
      for (const el of candidates) if (el.offsetParent !== null) return el;
      return document.querySelector('video');
    }
    return (
      document.querySelector('#movie_player video.html5-main-video') ||
      document.querySelector('ytd-player video.html5-main-video') ||
      document.querySelector('video[controls][src]') ||
      document.querySelector('video')
    );
  }

  // safe seek with tiny retry if video not ready yet
  function seekBy(delta) {
    const trySeek = (attempt = 0) => {
      const v = getActiveVideo();
      if (!v) { if (attempt < 10) setTimeout(() => trySeek(attempt + 1), 60); return; }
      const dur = Number.isFinite(v.duration) ? v.duration : 9e9;
      v.currentTime = Math.max(0, Math.min(dur, (v.currentTime || 0) + delta));
    };
    trySeek();
  }

  // header anchor
  function findLogoAnchor() {
    return (
      document.querySelector('a#logo') ||
      document.querySelector('ytd-topbar-logo-renderer a') ||
      document.querySelector('#logo-icon') ||
      document.querySelector('yt-icon-button#guide-button') ||
      null
    );
  }
  function positionHost() {
    if (!host) return;
    const logo = findLogoAnchor();
    if (logo) {
      const r = logo.getBoundingClientRect();
      const top = Math.max(MIN_TOP, Math.round(r.top + (r.height - BTN) / 2));
      const left = Math.round(r.right + OFFSET_X);
      host.style.top  = `${top}px`;
      host.style.left = `${left}px`;
    } else {
      host.style.top  = '12px';
      host.style.left = '16px';
    }
  }

  // -------- Transcript utilities --------
  function getTranscriptButtons() {
    let showBtn = null, hideBtn = null, closeBtn = null;
    const all = [
      ...document.querySelectorAll('button'),
      ...document.querySelectorAll('[role="button"]'),
      ...document.querySelectorAll('tp-yt-paper-button')
    ];
    for (const el of all) {
      const t = (el.textContent || '').trim().toLowerCase();
      if (!t) continue;
      if (t.includes('show transcript')) showBtn = el;
      if (t.includes('hide transcript')) hideBtn = el;
    }
    closeBtn = document.querySelector(
      'ytd-engagement-panel-section-list-renderer[target-id*="transcript"] yt-icon-button[aria-label*="Close"], ' +
      'ytd-engagement-panel-section-list-renderer[target-id*="transcript"] [aria-label*="Close transcript"]'
    );
    return { showBtn, hideBtn, closeBtn };
  }
  function isTranscriptVisible() {
    const { hideBtn } = getTranscriptButtons();
    if (hideBtn) return true;
    const panel =
      document.querySelector('ytd-engagement-panel-section-list-renderer[target-id*="transcript"]') ||
      document.querySelector('ytd-transcript-renderer') ||
      document.querySelector('ytd-transcript-search-panel-renderer');
    if (panel) {
      const visAttr = panel.getAttribute('visibility') || '';
      if (/EXPANDED/i.test(visAttr)) return true;
      if (panel.offsetParent !== null) return true;
    }
    return false;
  }
  function transcriptAvailable() {
    const { showBtn, hideBtn } = getTranscriptButtons();
    return !!(showBtn || hideBtn);
  }
  function toggleTranscript(btnUI) {
    const { showBtn, hideBtn, closeBtn } = getTranscriptButtons();
    if (isTranscriptVisible()) {
      if (hideBtn) hideBtn.click();
      else if (closeBtn) closeBtn.click();
    } else if (showBtn) {
      showBtn.click();
    }
    setTimeout(() => {
      const showing = isTranscriptVisible();
      if (btnUI && btnUI.firstChild) {
        btnUI.firstChild.textContent = showing ? 'playlist_remove' : 'text_ad';
        btnUI.title = showing ? 'Hide transcript' : 'Show transcript';
      }
    }, 50);
  }

  // --- build toolbar (Transcript, ←15, ←5, →5, →15)
  function build() {
    if (!ensureHost()) return false;
    ensureMaterialSymbols();

    if (bar.childElementCount === 0) {
      // Transcript
      btnTranscript = document.createElement('button');
      btnTranscript.className = 'btn';
      btnTranscript.title = 'Show transcript';
      btnTranscript.appendChild(ms('text_ad'));
      btnTranscript.addEventListener('click', () => toggleTranscript(btnTranscript));

      // Jumps
      btnBack15 = document.createElement('button');
      btnBack15.className = 'btn'; btnBack15.title = 'Back 15s';
      btnBack15.appendChild(ms('fast_rewind'));
      btnBack15.addEventListener('click', () => seekBy(-SEEK_15));

      btnBack5 = document.createElement('button');
      btnBack5.className = 'btn'; btnBack5.title = 'Back 5s';
      btnBack5.appendChild(ms('keyboard_double_arrow_left'));
      btnBack5.addEventListener('click', () => seekBy(-SEEK_5));

      btnFwd5 = document.createElement('button');
      btnFwd5.className = 'btn'; btnFwd5.title = 'Forward 5s';
      btnFwd5.appendChild(ms('keyboard_double_arrow_right'));
      btnFwd5.addEventListener('click', () => seekBy(SEEK_5));

      btnFwd15 = document.createElement('button');
      btnFwd15.className = 'btn'; btnFwd15.title = 'Forward 15s';
      btnFwd15.appendChild(ms('fast_forward'));
      btnFwd15.addEventListener('click', () => seekBy(SEEK_15));

      bar.append(btnTranscript, btnBack15, btnBack5, btnFwd5, btnFwd15);
    }

    positionHost();
    updateTranscriptState();
    mounted = true;

    // Observe for state changes only when mounted
    if (!mo) {
      mo = new MutationObserver(() => {
        positionHost();
        updateTranscriptState();
      });
    }
    mo.disconnect();
    mo.observe(document.documentElement, { childList: true, subtree: true });

    return true;
  }

  // --- unmount toolbar when leaving target routes
  function destroy() {
    mounted = false;
    if (mo) mo.disconnect();
    btnTranscript = btnBack15 = btnBack5 = btnFwd5 = btnFwd15 = null;
    if (host && host.parentNode) host.parentNode.removeChild(host);
    host = root = bar = null;
  }

  // --- router: mount/destroy on SPA changes
  function routeTick() {
    if (isTargetRoute()) {
      if (!mounted) build();
    } else {
      if (mounted) destroy();
    }
  }

  // boot + route guard
  function start() {
    if (document.body) { routeTick(); return; }
    const iv = setInterval(() => { if (document.body) { clearInterval(iv); routeTick(); } }, 50);
  }
  start();

  // keep positioned
  window.addEventListener('resize', () => { if (mounted) positionHost(); }, { passive: true });
  window.addEventListener('scroll', () => { if (mounted) positionHost(); }, { passive: true });

  // hook SPA navigations
  ['pushState','replaceState'].forEach(k=>{
    const o = history[k];
    history[k] = function(){ const r = o.apply(this, arguments);
      setTimeout(routeTick, 0);
      return r;
    };
  });
  window.addEventListener('popstate', () => setTimeout(routeTick, 0));

  // last-resort periodic check (in case YouTube uses internal router without history API)
  setInterval(routeTick, 800);
})();