Actually Working YouTube Miniplayer

Miniplayer when you scroll down under the video. (configurable!!!)

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Actually Working YouTube Miniplayer
// @author       Torkelicious
// @version      1.0
// @license      GPL-3.0-or-later
// @description  Miniplayer when you scroll down under the video. (configurable!!!)
// @icon         https://www.youtube.com/favicon.ico
// @match        *://www.youtube.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-start
// @namespace    https://greasyfork.org/users/1403155
// ==/UserScript==

(function () {
  'use strict';

  //  these settings are user-configurable
  const DEFAULTS = {
    pinnedVideoSize: '480x270',          // supports 'WIDTHxHEIGHT' or 'WIDTHxauto'
    pinnedVideoPosition: 'Bottom right', // one of the nine presets below
  };

  // Persisted settings
  const settings = {
    pinnedVideoSize: (() => {
      try {
        return GM_getValue('pinnedVideoSize', DEFAULTS.pinnedVideoSize);
      } catch (e) {
        console.warn('Failed to load pinnedVideoSize:', e);
        return DEFAULTS.pinnedVideoSize;
      }
    })(),
    pinnedVideoPosition: (() => {
      try {
        return GM_getValue('pinnedVideoPosition', DEFAULTS.pinnedVideoPosition);
      } catch (e) {
        console.warn('Failed to load pinnedVideoPosition:', e);
        return DEFAULTS.pinnedVideoPosition;
      }
    })(),
  };

  // Presets for the dropdowns
  const SIZE_PRESETS = [
    '320x180','426x240','480x270','640x360','854x480','960x540','1280x720',
    '480xauto','640xauto','854xauto'
  ];
  const POSITION_PRESETS = [
    'Top left','Top center','Top right',
    'Center left','Center','Center right',
    'Bottom left','Bottom center','Bottom right'
  ];

  // ID for config UI
  const CONFIG_OVERLAY_ID = 'yt-miniplayer-config-overlay';
  const CONFIG_STYLE_ID = 'yt-miniplayer-config-style';

  // DOM selectors as constants
  const SELECTORS = {
    moviePlayer: '#movie_player',
    below: '#below',
    watchFlexy: 'ytd-watch-flexy',
    video: '#movie_player video',
  };

  // State
  let isReady = false;
  let styleEl = null;
  let sentinelObserver = null;
  let flexyAttrObserver = null;
  let resizeListenerAttached = false;

  // Sticky only buttons
  let stickyCloseBtn = null;
  let stickyTopBtn = null;

  // ---------------- Config UI ----------------
  function ensureConfigCss() {
    if (document.getElementById(CONFIG_STYLE_ID)) return;
    GM_addStyle(`
#${CONFIG_OVERLAY_ID} {
  position: fixed; inset: 0; z-index: 10000;
  background: rgba(0,0,0,0.35);
  display: flex; align-items: center; justify-content: center;
}
#${CONFIG_OVERLAY_ID} .panel {
  background: #1f1f1f; color: #fff; min-width: 320px; max-width: 90vw;
  border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
  padding: 16px; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans";
}
#${CONFIG_OVERLAY_ID} .panel h2 { margin: 0 0 12px 0; font-size: 16px; font-weight: 600; }
#${CONFIG_OVERLAY_ID} .row { margin: 10px 0; }
#${CONFIG_OVERLAY_ID} label { display: block; margin-bottom: 6px; font-size: 13px; color: #ddd; }
#${CONFIG_OVERLAY_ID} select {
  width: 100%; padding: 6px 8px; border-radius: 8px; border: 1px solid #444; background: #2a2a2a; color: #fff;
}
#${CONFIG_OVERLAY_ID} .buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
#${CONFIG_OVERLAY_ID} button {
  appearance: none; border: 1px solid #555; background: #2b2b2b; color: #fff; padding: 6px 12px; border-radius: 8px; cursor: pointer;
}
#${CONFIG_OVERLAY_ID} button.primary { background: #3d6ae0; border-color: #2f55b5; }
    `).id = CONFIG_STYLE_ID;
  }

  function createEl(tag, props) {
    const el = document.createElement(tag);
    if (props) {
      for (const [k, v] of Object.entries(props)) {
        if (k === 'text') el.textContent = v;
        else if (k === 'children' && Array.isArray(v)) v.forEach(c => el.appendChild(c));
        else if (k === 'on') {
          for (const [evt, handler] of Object.entries(v)) el.addEventListener(evt, handler);
        } else el.setAttribute(k, v);
      }
    }
    return el;
  }
  function optionFor(value, label) {
    const o = document.createElement('option');
    o.value = value;
    o.textContent = label ?? value;
    return o;
  }

  function showConfigDialog() {
    ensureConfigCss();
    const old = document.getElementById(CONFIG_OVERLAY_ID);
    if (old && old.parentNode) old.parentNode.removeChild(old);

    const overlay = createEl('div', { id: CONFIG_OVERLAY_ID });
    overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });

    const panel = createEl('div', { class: 'panel' });
    const title = createEl('h2', { text: 'Miniplayer Settings' });

    // Size row
    const sizeRow = createEl('div', { class: 'row' });
    const sizeLabel = createEl('label', { text: 'Pinned size' });
    const sizeSelect = createEl('select');
    if (!SIZE_PRESETS.includes(settings.pinnedVideoSize)) {
      sizeSelect.appendChild(optionFor(settings.pinnedVideoSize, `${settings.pinnedVideoSize} (current)`));
    }
    SIZE_PRESETS.forEach(s => sizeSelect.appendChild(optionFor(s)));
    sizeSelect.value = settings.pinnedVideoSize;

    // Position row
    const posRow = createEl('div', { class: 'row' });
    const posLabel = createEl('label', { text: 'Pinned position' });
    const posSelect = createEl('select');
    if (!POSITION_PRESETS.includes(settings.pinnedVideoPosition)) {
      posSelect.appendChild(optionFor(settings.pinnedVideoPosition, `${settings.pinnedVideoPosition} (current)`));
    }
    POSITION_PRESETS.forEach(p => posSelect.appendChild(optionFor(p)));
    posSelect.value = settings.pinnedVideoPosition;

    // Buttons
    const buttons = createEl('div', { class: 'buttons' });
    const cancelBtn = createEl('button', { text: 'Cancel' });
    cancelBtn.addEventListener('click', () => overlay.remove());
    const saveBtn = createEl('button', { text: 'Save', class: 'primary' });
    saveBtn.addEventListener('click', () => {
      const newSize = sizeSelect.value;
      const newPos = posSelect.value;

      if (newSize !== settings.pinnedVideoSize) {
        settings.pinnedVideoSize = newSize;
        try {
          GM_setValue('pinnedVideoSize', newSize);
        } catch (e) {
          console.error('Failed to save pinnedVideoSize:', e);
        }
      }
      if (newPos !== settings.pinnedVideoPosition) {
        settings.pinnedVideoPosition = newPos;
        try {
          GM_setValue('pinnedVideoPosition', newPos);
        } catch (e) {
          console.error('Failed to save pinnedVideoPosition:', e);
        }
      }

      rebuildStickyStyle();
      scheduleResize();
      overlay.remove();
    });

    buttons.appendChild(cancelBtn);
    buttons.appendChild(saveBtn);

    sizeRow.appendChild(sizeLabel);
    sizeRow.appendChild(sizeSelect);
    posRow.appendChild(posLabel);
    posRow.appendChild(posSelect);

    panel.appendChild(title);
    panel.appendChild(sizeRow);
    panel.appendChild(posRow);
    panel.appendChild(buttons);

    overlay.appendChild(panel);
    document.documentElement.appendChild(overlay);
    sizeSelect.focus();
  }

  GM_registerMenuCommand('Configure Miniplayer', showConfigDialog);

  // ---------------- Core ----------------
  function parseSizeTokens(text) {
    const m = (text || '').match(/\d+|auto/gi);
    return m || ['480', '270'];
  }
  function getCurrentVideoAspect() {
    const v = document.querySelector(SELECTORS.video);
    if (v && v.videoWidth && v.videoHeight) return v.videoWidth / v.videoHeight;
    const p = document.getElementById('movie_player');
    if (p && p.clientWidth && p.clientHeight) return p.clientWidth / p.clientHeight;
    return 16 / 9;
  }
  function computePinnedSize() {
    const tokens = parseSizeTokens(settings.pinnedVideoSize);
    const wantedW = tokens[0] && tokens[0] !== 'auto' ? parseInt(tokens[0], 10) : 480;
    const wantAutoH = (tokens[1] || '').toLowerCase() === 'auto';
    const w = Number.isFinite(wantedW) ? wantedW : 480;
    let h;
    if (wantAutoH) {
      const ar = getCurrentVideoAspect();
      h = Math.round(w / (ar || (16 / 9)));
    } else {
      const wantedH = tokens[1] && tokens[1] !== 'auto' ? parseInt(tokens[1], 10) : 270;
      h = Number.isFinite(wantedH) ? wantedH : 270;
    }
    return [w, h];
  }

  // Debounced resize dispatch to avoid excessive events !!!
  let resizeRafId = null;
  function scheduleResize() {
    if (resizeRafId) return;
    resizeRafId = requestAnimationFrame(() => {
      window.dispatchEvent(new Event('resize'));
      resizeRafId = null;
    });
  }

  function rebuildStickyStyle() {
    const [w, h] = computePinnedSize();
    const pos = (settings.pinnedVideoPosition || DEFAULTS.pinnedVideoPosition).toLowerCase();

    const cssProp = { top: 'initial', bottom: 'initial', right: 'initial', left: 'initial', transform: 'initial' };

    // i should make this configurable maybe?
    const GAP = '10px';

    // Vertical
    if (pos.startsWith('top')) cssProp.top = GAP;
    else if (pos.startsWith('center')) {
      cssProp.top = '50vh';
      cssProp.transform = 'translateY(-50%)';
    } else cssProp.bottom = GAP; // default bottom

    // Horizontal
    if (pos.endsWith('left')) cssProp.left = GAP;
    else if (pos.endsWith('center')) {
      cssProp.left = '50vw';
      cssProp.transform = (cssProp.transform === 'initial' ? '' : cssProp.transform + ' ') + 'translateX(-50%)';
    } else cssProp.right = GAP; // default right

    const css = `
/* Sticky player container positioning (size/pos only) - high specificity */
.yttw-sticky-player.yttw-sticky-player ytd-watch-flexy #player-container.ytd-watch-flexy {
  position: fixed !important;
  width: ${w}px !important;
  height: ${h}px !important;
  top: ${cssProp.top} !important;
  bottom: ${cssProp.bottom} !important;
  right: ${cssProp.right} !important;
  left: ${cssProp.left} !important;
  transform: ${cssProp.transform} !important;
  z-index: 9999 !important;
  overflow: hidden !important;
}

/* Fill the sticky box and avoid cropping */
.yttw-sticky-player.yttw-sticky-player #movie_player,
.yttw-sticky-player.yttw-sticky-player .html5-video-container {
  width: 100% !important;
  height: 100% !important;
  max-width: 100% !important;
  max-height: 100% !important;
  min-width: 0 !important;
  min-height: 0 !important;
}
.yttw-sticky-player.yttw-sticky-player #movie_player video {
  width: 100% !important;
  height: 100% !important;
  max-width: 100% !important;
  max-height: 100% !important;
  object-fit: contain !important;
}

/* Sticky-only buttons; minimal styles */
.yttw-sticky-player ytd-watch-flexy #movie_player:hover .yttw-sticky-player-button {
  opacity: 1;
  visibility: visible;
}
.yttw-sticky-player-button {
  display: flex !important;
  align-items: center;
  justify-content: center;
  opacity: 0;
  visibility: hidden;
  position: absolute;
  cursor: pointer;
  top: 8px;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  z-index: 60;
  background-color: rgba(0, 0, 0, 0.4);
  transition: opacity .2s, visibility .2s;
  border: none;
  padding: 0;
}
`;

    if (!styleEl) {
      styleEl = document.createElement('style');
      styleEl.id = 'yt-miniplayer-sticky-style';
      document.documentElement.appendChild(styleEl);
    }
    styleEl.textContent = css;
  }

  // Buttons when sticky is active
  function createSvgIcon(paths) {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('fill', 'white');
    svg.style.width = '22px';
    svg.style.height = '22px';
    paths.forEach(d => {
      const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      p.setAttribute('d', d);
      svg.appendChild(p);
    });
    return svg;
  }
  function ensureStickyButtons() {
    if (stickyCloseBtn && stickyTopBtn) return;
    const moviePlayer = document.getElementById('movie_player');
    if (!moviePlayer) return;

    stickyCloseBtn = document.createElement('button');
    stickyCloseBtn.className = 'yttw-sticky-player-button';
    stickyCloseBtn.title = 'Close Miniplayer';
    stickyCloseBtn.setAttribute('aria-label', 'Close Miniplayer');
    stickyCloseBtn.setAttribute('role', 'button');
    stickyCloseBtn.style.left = '8px';
    stickyCloseBtn.appendChild(createSvgIcon([
      'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'
    ]));
    stickyCloseBtn.addEventListener('click', (e) => {
      e.preventDefault(); e.stopPropagation();
      document.body.classList.remove('yttw-sticky-player');
      removeStickyButtons();
    });

    stickyTopBtn = document.createElement('button');
    stickyTopBtn.className = 'yttw-sticky-player-button';
    stickyTopBtn.title = 'Scroll to Top';
    stickyTopBtn.setAttribute('aria-label', 'Scroll to Top');
    stickyTopBtn.setAttribute('role', 'button');
    stickyTopBtn.style.right = '8px';
    stickyTopBtn.appendChild(createSvgIcon([
      'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z'
    ]));
    stickyTopBtn.addEventListener('click', (e) => {
      e.preventDefault(); e.stopPropagation();
      window.scrollTo({ top: 0, behavior: 'smooth' });
    });

    moviePlayer.append(stickyCloseBtn, stickyTopBtn);
  }
  function removeStickyButtons() {
    if (stickyCloseBtn) stickyCloseBtn.remove();
    if (stickyTopBtn) stickyTopBtn.remove();
    stickyCloseBtn = stickyTopBtn = null;
  }

  // Under video check using #below
  function toggleStickyPlayer(entries) {
    if (!isReady) return;

    const belowElement = document.getElementById('below');
    if (!belowElement) return;

    // Adjust sticky box in case aspect changed just before toggling
    rebuildStickyStyle();

    const belowTopAbs = belowElement.getBoundingClientRect().top + window.scrollY;

    if (entries[0].isIntersecting) {
      document.body.classList.remove('yttw-sticky-player');
      removeStickyButtons();
    } else {
      const scrolledUnderVideo = window.scrollY >= (belowTopAbs - 1);
      if (scrolledUnderVideo) {
        document.body.classList.add('yttw-sticky-player');
        ensureStickyButtons();
      } else {
        document.body.classList.remove('yttw-sticky-player');
        removeStickyButtons();
      }
    }

    scheduleResize();
  }

  function setupElements() {
    const moviePlayer = document.getElementById('movie_player');
    const belowElement = document.getElementById('below');
    if (!moviePlayer || !belowElement) return;

    rebuildStickyStyle();

    // Sentinel at the boundary between player and below
    const intersectionTrigger = document.createElement('div');
    intersectionTrigger.id = 'miniplayer-intersection-trigger';
    intersectionTrigger.style.position = 'absolute';
    intersectionTrigger.style.top = '0';
    intersectionTrigger.style.height = '1px';
    if (!belowElement.style.position || belowElement.style.position === 'static') {
      belowElement.style.position = 'relative';
    }
    belowElement.appendChild(intersectionTrigger);

    // Observe the sentinel
    sentinelObserver = new IntersectionObserver(toggleStickyPlayer);
    sentinelObserver.observe(intersectionTrigger);

    // Observe ytd-watch-flexy for mode changes (theater/miniplayer/fullscreen)
    const flexy = document.querySelector(SELECTORS.watchFlexy);
    if (flexy) {
      flexyAttrObserver = new MutationObserver(() => {
        const wasSticky = document.body.classList.contains('yttw-sticky-player');
        // Hide sticky during mode transition
        document.body.classList.remove('yttw-sticky-player');
        removeStickyButtons();

        rebuildStickyStyle();
        window.requestAnimationFrame(() => {
          scheduleResize();
          const belowTopAbs = document.getElementById('below')?.getBoundingClientRect().top + window.scrollY || Infinity;
          if (wasSticky && window.scrollY >= (belowTopAbs - 1)) {
            document.body.classList.add('yttw-sticky-player');
            ensureStickyButtons();
            window.requestAnimationFrame(() => scheduleResize());
          }
        });
      });
      flexyAttrObserver.observe(flexy, { attributes: true, attributeFilter: ['theater', 'fullscreen', 'miniplayer'] });
    }

    // React to fullscreen changes
    document.addEventListener('fullscreenchange', () => {
      const wasSticky = document.body.classList.contains('yttw-sticky-player');
      if (document.fullscreenElement) {
        document.body.classList.remove('yttw-sticky-player');
        removeStickyButtons();
      }
      rebuildStickyStyle();
      scheduleResize();
      if (!document.fullscreenElement && wasSticky) {
        document.body.classList.add('yttw-sticky-player');
        ensureStickyButtons();
        window.requestAnimationFrame(() => scheduleResize());
      }
    });

    // Recompute sticky size on viewport changes & prevent duplicate listeners)
    if (!resizeListenerAttached) {
      window.addEventListener('resize', rebuildStickyStyle);
      resizeListenerAttached = true;
    }

    document.body.classList.add('miniplayer-userscript-loaded');
    isReady = true;
  }

  function initializeMiniplayer() {
    // Only run on watch pages
    if (!window.location.pathname.startsWith('/watch')) {
      isReady = false;
      return;
    }
    if (document.body.classList.contains('miniplayer-userscript-loaded')) {
      isReady = true;
      return;
    }

    // Check if elements already exist before observing
    if (document.getElementById('movie_player') && document.getElementById('below')) {
      setupElements();
      return;
    }

    // Wait for player and below to exist
    const readyObs = new MutationObserver((mutations, obs) => {
      if (document.getElementById('movie_player') && document.getElementById('below')) {
        setupElements();
        obs.disconnect();
      }
    });
    readyObs.observe(document.body, { childList: true, subtree: true });
  }

  function cleanup() {
    document.body.classList.remove('miniplayer-userscript-loaded', 'yttw-sticky-player');
    isReady = false;

    if (sentinelObserver) { sentinelObserver.disconnect(); sentinelObserver = null; }
    if (flexyAttrObserver) { flexyAttrObserver.disconnect(); flexyAttrObserver = null; }

    const oldTrigger = document.getElementById('miniplayer-intersection-trigger');
    if (oldTrigger) oldTrigger.remove();

    if (styleEl && styleEl.parentNode) {
      styleEl.parentNode.removeChild(styleEl);
      styleEl = null;
    }

    if (resizeListenerAttached) {
      window.removeEventListener('resize', rebuildStickyStyle);
      resizeListenerAttached = false;
    }

    removeStickyButtons();

    // Remove config overlay if open during navigation
    const overlay = document.getElementById(CONFIG_OVERLAY_ID);
    if (overlay) overlay.remove();
  }

  // SPA navigation
  document.addEventListener('yt-navigate-finish', () => {
    cleanup();
    initializeMiniplayer();
  });

  // Initialize on load
  initializeMiniplayer();

})();