YouTube Playlists Buttons In Video Header

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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();
})();