YouTube Playlists Buttons In Video Header

在 YouTube 视频页标题下、频道栏上方插入播放列表按钮,支持桌面和移动端;Shorts 自动使用悬浮备用位置;新增“全清单”按钮

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         YouTube Playlists Buttons In Video Header
// @namespace    c0d3r
// @license      MIT
// @version      1.2.0
// @description  在 YouTube 视频页标题下、频道栏上方插入播放列表按钮,支持桌面和移动端;Shorts 自动使用悬浮备用位置;新增“全清单”按钮
// @author       c0d3r + ChatGPT
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  'use strict';

  const INLINE_ID = 'yavp-inline-bar';
  const FLOAT_ID = 'yavp-float-bar';
  const STYLE_ID = 'yavp-inline-style';
  const STORAGE_PREFIX = 'yavp_inline_';

  const playlists = [
    { label: '全部', title: 'All Uploads', prefix: 'UU' },
    { label: '热门', title: 'Popular Uploads', prefix: 'PU' },
    { label: '视频', title: 'All Videos', prefix: 'UULF' },
    { label: '视频热', title: 'Popular Videos', prefix: 'UULP' },
    { label: 'Shorts', title: 'All Shorts', prefix: 'UUSH' },
    { label: '短热', title: 'Popular Shorts', prefix: 'UUPS' },
    { label: '直播', title: 'All Streams', prefix: 'UULV' },
    { label: '直播热', title: 'Popular Streams', prefix: 'UUPV' },
    { label: '会员', title: 'Members-Only Videos', prefix: 'UUMO' }
  ];

  const options = {
    playNext: getValue('playNext', true),
    newTabs: getValue('newTabs', false)
  };

  let lastKey = '';

  function getValue(key, fallback) {
    try {
      if (typeof GM_getValue === 'function') {
        return GM_getValue(key, fallback);
      }
    } catch (error) {}

    try {
      const raw = localStorage.getItem(STORAGE_PREFIX + key);
      return raw === null ? fallback : JSON.parse(raw);
    } catch (error) {
      return fallback;
    }
  }

  function setValue(key, value) {
    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue(key, value);
      }
    } catch (error) {}

    try {
      localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value));
    } catch (error) {}
  }

  function isWatchPage() {
    return location.pathname === '/watch';
  }

  function isShortsPage() {
    return location.pathname.startsWith('/shorts/');
  }

  function isVideoPage() {
    return isWatchPage() || isShortsPage();
  }

  function getChannelId() {
    const meta = document.querySelector('meta[itemprop="channelId"][content]');
    if (meta && /^UC[\w-]+$/.test(meta.content)) {
      return meta.content.trim();
    }

    const link = document.querySelector(
      'a[href*="/channel/UC"], a[href^="/channel/UC"], a[href*="youtube.com/channel/UC"]'
    );
    if (link) {
      const match = link.href.match(/\/channel\/(UC[\w-]+)/);
      if (match) {
        return match[1];
      }
    }

    const details =
      window.ytInitialPlayerResponse?.videoDetails ||
      window.__INITIAL_PLAYER_RESPONSE__?.videoDetails;

    if (details && /^UC[\w-]+$/.test(details.channelId || '')) {
      return details.channelId;
    }

    return '';
  }

  function getChannelBasePath() {
    const selectors = [
      '#owner a[href]',
      'ytd-watch-metadata #owner a[href]',
      'ytm-slim-owner-renderer a[href]',
      'ytm-channel-bar-renderer a[href]'
    ];

    for (const selector of selectors) {
      const link = document.querySelector(selector);
      const href = link && link.getAttribute('href');
      if (!href) {
        continue;
      }

      try {
        const url = new URL(href, location.origin);
        let path = url.pathname.replace(/\/+$/, '');

        path = path.replace(
          /\/(featured|videos|shorts|streams|playlists|community|about)$/i,
          ''
        );

        if (/^(\/@[^/]+|\/channel\/UC[\w-]+|\/c\/[^/]+|\/user\/[^/]+)$/i.test(path)) {
          return path;
        }
      } catch (error) {}
    }

    const channelId = getChannelId();
    return channelId ? '/channel/' + channelId : '';
  }

  function buildPlaylistUrl(prefix, channelId) {
    const pureId = channelId.startsWith('UC') ? channelId.slice(2) : channelId;
    let url = location.origin + '/playlist?list=' + prefix + pureId;

    if ((options.playNext && prefix !== 'UUMO') || prefix === 'PU') {
      url += '&playnext=1';
    }

    return url;
  }

  function buildChannelPlaylistsUrl() {
    const base = getChannelBasePath();
    return base ? location.origin + base + '/playlists' : '';
  }

  function openUrl(url) {
    if (!url) {
      return;
    }

    if (options.newTabs) {
      window.open(url, '_blank', 'noopener');
      return;
    }

    location.assign(url);
  }

  function ensureStyles() {
    if (document.getElementById(STYLE_ID)) {
      return;
    }

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = `
      #${INLINE_ID} {
        width: 100%;
        margin: 8px 0 12px 0;
      }

      #${FLOAT_ID} {
        position: fixed;
        left: 12px;
        right: 12px;
        bottom: calc(env(safe-area-inset-bottom, 0px) + 12px);
        z-index: 2147483647;
        padding: 10px;
        border-radius: 16px;
        background: var(--yt-spec-base-background, #fff);
        border: 1px solid rgba(127, 127, 127, 0.18);
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
      }

      html[dark] #${FLOAT_ID} {
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
      }

      #${FLOAT_ID}.is-hidden {
        display: none !important;
      }

      #${INLINE_ID} .yavp-row,
      #${FLOAT_ID} .yavp-row {
        display: flex;
        align-items: center;
        gap: 8px;
        overflow-x: auto;
        overflow-y: hidden;
        white-space: nowrap;
        padding: 2px 0;
        scrollbar-width: thin;
      }

      #${INLINE_ID} .yavp-row::-webkit-scrollbar,
      #${FLOAT_ID} .yavp-row::-webkit-scrollbar {
        height: 6px;
      }

      #${INLINE_ID} .yavp-row::-webkit-scrollbar-thumb,
      #${FLOAT_ID} .yavp-row::-webkit-scrollbar-thumb {
        background: rgba(127, 127, 127, 0.35);
        border-radius: 999px;
      }

      #${INLINE_ID} button,
      #${FLOAT_ID} button {
        flex: 0 0 auto;
        appearance: none;
        border: 1px solid var(--yt-spec-10-percent-layer, rgba(0, 0, 0, 0.12));
        background: var(--yt-spec-badge-chip-background, rgba(0, 0, 0, 0.05));
        color: var(--yt-spec-text-primary, #0f0f0f);
        border-radius: 999px;
        padding: 8px 12px;
        margin: 0;
        min-height: 32px;
        line-height: 1;
        font: 500 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
        cursor: pointer;
      }

      #${INLINE_ID} button:hover,
      #${FLOAT_ID} button:hover {
        filter: brightness(0.97);
      }

      #${INLINE_ID} button:active,
      #${FLOAT_ID} button:active {
        transform: translateY(1px);
      }

      #${INLINE_ID} .yavp-toggle[data-on="1"],
      #${FLOAT_ID} .yavp-toggle[data-on="1"] {
        background: var(--yt-spec-call-to-action, #065fd4);
        color: var(--yt-spec-text-primary-inverse, #fff);
        border-color: transparent;
        font-weight: 700;
      }
    `;
    (document.head || document.documentElement).appendChild(style);
  }

  function removeBars() {
    document.getElementById(INLINE_ID)?.remove();
    document.getElementById(FLOAT_ID)?.remove();
  }

  function updateFloatVisibility() {
    const floatBar = document.getElementById(FLOAT_ID);
    if (!floatBar) {
      return;
    }
    floatBar.classList.toggle('is-hidden', !!document.fullscreenElement);
  }

  function getInlineMount() {
    if (!isWatchPage()) {
      return null;
    }

    if (location.hostname === 'www.youtube.com') {
      const topRow = document.querySelector('ytd-watch-metadata #top-row');
      if (topRow && topRow.parentElement) {
        return {
          parent: topRow.parentElement,
          before: topRow
        };
      }

      const owner = document.querySelector('ytd-watch-metadata #owner');
      if (owner && owner.parentElement) {
        return {
          parent: owner.parentElement,
          before: owner
        };
      }
    }

    if (location.hostname === 'm.youtube.com') {
      const owner = document.querySelector('ytm-slim-owner-renderer, ytm-channel-bar-renderer');
      if (owner && owner.parentElement) {
        return {
          parent: owner.parentElement,
          before: owner
        };
      }

      const meta = document.querySelector('ytm-slim-video-metadata-section-renderer, ytm-watch-metadata');
      if (meta && meta.parentElement) {
        return {
          parent: meta.parentElement,
          before: meta.nextSibling || null
        };
      }
    }

    return null;
  }

  function createButton(text, title, handler, isToggle, isOn) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = text;
    button.title = title || text;

    if (isToggle) {
      button.className = 'yavp-toggle';
      button.dataset.on = isOn ? '1' : '0';
    }

    button.addEventListener('click', function (event) {
      event.preventDefault();
      event.stopPropagation();
      handler();
    });

    return button;
  }

  function buildBar(mode, channelId) {
    const root = document.createElement('div');
    root.id = mode === 'inline' ? INLINE_ID : FLOAT_ID;

    const row = document.createElement('div');
    row.className = 'yavp-row';

    playlists.forEach(function (item) {
      row.appendChild(
        createButton(item.label, item.title, function () {
          openUrl(buildPlaylistUrl(item.prefix, channelId));
        }, false, false)
      );
    });

    const allPlaylistsUrl = buildChannelPlaylistsUrl();
    if (allPlaylistsUrl) {
      row.appendChild(
        createButton('全清单', '当前频道全部播放清单', function () {
          openUrl(allPlaylistsUrl);
        }, false, false)
      );
    }

    row.appendChild(
      createButton('自动播', '切换 playnext=1', function () {
        options.playNext = !options.playNext;
        setValue('playNext', options.playNext);
        render(true);
      }, true, options.playNext)
    );

    row.appendChild(
      createButton('新标签', '新标签页打开', function () {
        options.newTabs = !options.newTabs;
        setValue('newTabs', options.newTabs);
        render(true);
      }, true, options.newTabs)
    );

    root.appendChild(row);
    return root;
  }

  function render(force) {
    if (!document.body) {
      return;
    }

    if (!isVideoPage()) {
      removeBars();
      lastKey = '';
      return;
    }

    const channelId = getChannelId();
    if (!channelId) {
      removeBars();
      lastKey = '';
      return;
    }

    ensureStyles();

    const mount = getInlineMount();
    const mode = mount ? 'inline' : 'float';
    const currentKey = [
      location.href,
      channelId,
      mode,
      options.playNext,
      options.newTabs
    ].join('|');

    const existing = document.getElementById(mode === 'inline' ? INLINE_ID : FLOAT_ID);

    if (!force && existing && existing.isConnected && lastKey === currentKey) {
      updateFloatVisibility();
      return;
    }

    removeBars();

    const bar = buildBar(mode, channelId);

    if (mount) {
      mount.parent.insertBefore(bar, mount.before);
    } else {
      document.body.appendChild(bar);
    }

    lastKey = currentKey;
    updateFloatVisibility();
  }

  function scheduleRender() {
    window.setTimeout(render, 50);
    window.setTimeout(render, 300);
    window.setTimeout(render, 900);
    window.setTimeout(render, 1800);
  }

  window.addEventListener('yt-navigate-finish', scheduleRender, true);
  window.addEventListener('yt-page-data-updated', scheduleRender, true);
  window.addEventListener('popstate', scheduleRender, true);
  document.addEventListener('fullscreenchange', updateFloatVisibility, true);

  const rawPushState = history.pushState;
  history.pushState = function () {
    const result = rawPushState.apply(this, arguments);
    scheduleRender();
    return result;
  };

  const rawReplaceState = history.replaceState;
  history.replaceState = function () {
    const result = rawReplaceState.apply(this, arguments);
    scheduleRender();
    return result;
  };

  setInterval(render, 1500);
  scheduleRender();
})();