Greasy Fork

Greasy Fork is available in English.

YouTube Mobile 体验增强版

自动@回复 + 引用 + 播放列表 + 全局速度控制 + 自动跳下一条 (增强)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Mobile 体验增强版
// @namespace    yt-mobile-autoreply-ui
// @version      3.8
// @description  自动@回复 + 引用 + 播放列表 + 全局速度控制 + 自动跳下一条 (增强)
// @match        https://m.youtube.com/*
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const LOG = (...args) => console.log('[YT-PL]', ...args);

  function showDebugMsg(msg) {
    let box = document.getElementById('yt-debug-msg');
    if (!box) {
      box = document.createElement('div');
      box.id = 'yt-debug-msg';
      Object.assign(box.style, {
        position: 'fixed',
        bottom: '180px',
        left: '50%',
        transform: 'translateX(-50%)',
        background: 'rgba(0,0,0,0.85)',
        color: '#fff',
        fontSize: '13px',
        padding: '8px 16px',
        borderRadius: '20px',
        zIndex: 999999
      });
      document.body.appendChild(box);
    }
    box.textContent = msg;
    box.style.opacity = '1';
    clearTimeout(box._timer);
    box._timer = setTimeout(() => box.style.opacity = '0', 1800);
  }

  /* ====== 数据 Keys ====== */
  const KEY_PLAYLIST = 'yt_mobile_playlist';
  const KEY_PLAYED_LIST = 'yt_mobile_played';
  let playlist = GM_getValue(KEY_PLAYLIST, []);
  let playedList = GM_getValue(KEY_PLAYED_LIST, []);

  function markPlayed(id) {
    if (id && !playedList.includes(id)) {
      playedList.push(id);
      GM_setValue(KEY_PLAYED_LIST, playedList);
    }
  }

  function isPlayed(id) {
    return playedList.includes(id);
  }

  /* ====== 按钮组容器 ====== */
  function createButtonContainer() {
    if (document.getElementById('yt-btn-container')) return;
    const container = document.createElement('div');
    container.id = 'yt-btn-container';
    Object.assign(container.style, {
      position: 'fixed',
      bottom: '12px',
      left: '12px',
      display: 'flex',
      flexDirection: 'column',
      gap: '8px',
      zIndex: 999998
    });
    document.body.appendChild(container);
  }

  /* ====== 引用开关 ====== */
  const KEY_ENABLE_QUOTE = 'enable_quote';
  let isQuoteEnabled = GM_getValue(KEY_ENABLE_QUOTE, false);

  function createQuoteSwitch() {
    if (document.getElementById('yt-quote-switch-btn')) return;
    const btn = document.createElement('div');
    btn.id = 'yt-quote-switch-btn';
    btn.textContent = '❝';
    Object.assign(btn.style, {
      width: '42px',
      height: '42px',
      borderRadius: '50%',
      backgroundColor: isQuoteEnabled ? '#2ba640' : 'rgba(0,0,0,0.6)',
      color: isQuoteEnabled ? '#fff' : '#ccc',
      fontSize: '24px',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      cursor: 'pointer'
    });
    btn.title = '引用模式开关';
    btn.onclick = () => {
      isQuoteEnabled = !isQuoteEnabled;
      GM_setValue(KEY_ENABLE_QUOTE, isQuoteEnabled);
      btn.style.backgroundColor = isQuoteEnabled ? '#2ba640' : 'rgba(0,0,0,0.6)';
      btn.style.color = isQuoteEnabled ? '#fff' : '#ccc';
      showDebugMsg(isQuoteEnabled ? '引用: 已开启' : '引用: 已关闭');
    };
    document.getElementById('yt-btn-container').appendChild(btn);
  }

  /* ====== 播放列表 ====== */
  function savePlaylist() {
    GM_setValue(KEY_PLAYLIST, playlist);
    LOG('Playlist saved', playlist);
  }

  function addToPlaylist(item) {
    if (playlist.find(v => v.id === item.id)) {
      showDebugMsg('⚠ 已在播放列表');
      return;
    }
    playlist.push(item);
    savePlaylist();
    showDebugMsg('🎵 已加入播放列表');
  }

  function removeFromPlaylist(id) {
    playlist = playlist.filter(v => v.id !== id);
    savePlaylist();
    renderPlaylistPanel();
  }

  function createPlaylistButton() {
    if (document.getElementById('yt-playlist-btn')) return;
    const btn = document.createElement('div');
    btn.id = 'yt-playlist-btn';
    btn.textContent = '🎵';
    Object.assign(btn.style, {
      width: '42px',
      height: '42px',
      borderRadius: '50%',
      backgroundColor: '#e91e63',
      color: '#fff',
      fontSize: '22px',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      cursor: 'pointer'
    });
    btn.title = '播放列表';
    btn.onclick = togglePlaylistPanel;
    document.getElementById('yt-btn-container').appendChild(btn);
  }

  function clearPlaylist() {
    if (!confirm('确认要清空播放列表吗?此操作不可撤销。')) return;
    playlist = [];
    playedList = [];
    GM_setValue(KEY_PLAYLIST, playlist);
    GM_setValue(KEY_PLAYED_LIST, playedList);
    renderPlaylistPanel();
    showDebugMsg('播放列表已清空');
  }

  function togglePlaylistPanel() {
    const panel = document.getElementById('yt-playlist-panel');
    if (panel) panel.remove();
    else renderPlaylistPanel();
  }

  function renderPlaylistPanel() {
    const old = document.getElementById('yt-playlist-panel');
    if (old) old.remove();

    const panel = document.createElement('div');
    panel.id = 'yt-playlist-panel';
    Object.assign(panel.style, {
      position: 'fixed',
      bottom: '12px',
      left: '72px',
      width: '300px',
      maxHeight: '60vh',
      overflowY: 'auto',
      backgroundColor: '#222',
      color: '#fff',
      padding: '8px',
      borderRadius: '8px',
      fontSize: '13px',
      zIndex: 999999
    });

    const header = document.createElement('div');
    Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center' });

    const title = document.createElement('div');
    title.textContent = `🎶 Playlist (${playlist.length})`;

    const clearBtn = document.createElement('button');
    clearBtn.textContent = '清空';
    clearBtn.style.fontSize = '12px';
    clearBtn.onclick = clearPlaylist;

    header.appendChild(title);
    header.appendChild(clearBtn);
    panel.appendChild(header);

    playlist.forEach(item => {
      const row = document.createElement('div');
      Object.assign(row.style, {
        display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px'
      });

      const lbl = document.createElement('span');
      lbl.textContent = item.title || item.id;
      lbl.style.cursor = 'pointer';
      lbl.style.color = isPlayed(item.id) ? '#888' : '#fff';
      lbl.onclick = () => location.href = item.url;

      const ctrl = document.createElement('div');

      const playBtn = document.createElement('button');
      playBtn.textContent = '▶';
      playBtn.onclick = () => location.href = item.url;

      const delBtn = document.createElement('button');
      delBtn.textContent = '❌';
      delBtn.onclick = () => removeFromPlaylist(item.id);

      ctrl.appendChild(playBtn);
      ctrl.appendChild(delBtn);

      row.appendChild(lbl);
      row.appendChild(ctrl);
      panel.appendChild(row);
    });

    document.body.appendChild(panel);
  }

  /* ====== 标题加号 ====== */
  function scanVideoEntries() {
    document.querySelectorAll('h3.media-item-headline').forEach(headline => {
      if (headline.dataset.plBound) return;
      try {
        const span = headline.querySelector('span[role="text"]');
        if (!span) return;
        const titleText = span.textContent.trim();
        if (!titleText) return;

        const btn = document.createElement('span');
        btn.textContent = '➕';
        Object.assign(btn.style, {
          marginRight: '6px',
          color: '#0f0',
          cursor: 'pointer',
          fontSize: '16px',
          fontWeight: 'bold'
        });
        btn.title = '加入播放列表';

        btn.onclick = e => {
          e.stopPropagation();
          e.preventDefault();
          let url = null;
          const parentA = headline.closest('a[href*="/watch"]');
          if (parentA) url = parentA.href;
          if (!url) {
            showDebugMsg('⚠ 无法提取视频链接');
            return;
          }
          const vid = new URL(url, location.origin).searchParams.get('v');
          addToPlaylist({ id: vid, title: titleText, url });
        };

        span.parentNode.insertBefore(btn, span);
        headline.dataset.plBound = '1';
      } catch (err) {
        LOG('scanVideoEntries err', err);
      }
    });
  }

  /* ====== 结束检测 (增强) ====== */
  function detectVideoEnd(videoEl) {
    if (!videoEl) return;

    let triggered = false;
    const tryNext = () => {
      if (triggered) return;
      triggered = true;
      playNextInPlaylist();
    };

    // 进度快到结尾
    videoEl.addEventListener('timeupdate', () => {
      if (!videoEl.duration) return;
      if (videoEl.currentTime >= videoEl.duration - 0.25) tryNext();
    });

    // 观察 DOM 变化
    new MutationObserver(() => {
      const nextBtn = document.querySelector(
        '.player-controls-middle-core-buttons.center button[aria-label="Next video"]:not([aria-disabled="true"])'
      );
      if (nextBtn) tryNext();
    }).observe(document.body, { subtree: true, childList: true });
  }

  /* ====== 速度面板 ====== */
  const SPEED_OPTIONS = [0.25, 0.5, 1.0, 1.25, 1.5, 1.75, 2.0];
  let currentSpeed = GM_getValue('yt_mobile_speed', 1.0);

  function createSpeedControlButton() {
    if (document.getElementById('yt-speed-btn')) return;
    const btn = document.createElement('div');
    btn.id = 'yt-speed-btn';
    btn.textContent = '⏩';
    Object.assign(btn.style, {
      width: '42px',
      height: '42px',
      borderRadius: '50%',
      backgroundColor: '#007acc',
      color: '#fff',
      fontSize: '22px',
      display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer'
    });
    btn.title = `播放速度 (${currentSpeed}x)`;
    btn.onclick = toggleSpeedPanel;
    document.getElementById('yt-btn-container').appendChild(btn);
  }

  function toggleSpeedPanel() {
    const panel = document.getElementById('yt-speed-panel');
    if (panel) panel.remove();
    else renderSpeedPanel();
  }

  function renderSpeedPanel() {
    const old = document.getElementById('yt-speed-panel');
    if (old) old.remove();

    const panel = document.createElement('div');
    panel.id = 'yt-speed-panel';
    Object.assign(panel.style, {
      position: 'fixed',
      bottom: '12px',
      left: '72px',
      backgroundColor: '#333',
      color: '#fff',
      padding: '8px',
      borderRadius: '8px',
      fontSize: '13px',
      zIndex: 999999
    });

    SPEED_OPTIONS.forEach(sp => {
      const b = document.createElement('button');
      b.textContent = `${sp}x`;
      b.style.margin = '4px';
      b.onclick = () => {
        currentSpeed = sp;
        GM_setValue('yt_mobile_speed', currentSpeed);
        applySpeedToVideo();
        showDebugMsg(`播放速度设为 ${currentSpeed}x`);
        document.getElementById('yt-speed-btn').title = `播放速度 (${currentSpeed}x)`;
        panel.remove();
      };
      panel.appendChild(b);
    });

    document.body.appendChild(panel);
  }

  function applySpeedToVideo() {
    const videoEl = document.querySelector('video');
    if (videoEl) {
      try {
        videoEl.playbackRate = currentSpeed;
        videoEl.addEventListener('play', () => {
          const currentVid = new URL(location.href).searchParams.get('v');
          markPlayed(currentVid);
        });
        detectVideoEnd(videoEl);
      } catch {}
    }
  }

  function playNextInPlaylist() {
    const currentVid = new URL(location.href).searchParams.get('v');
    const idx = playlist.findIndex(v => v.id === currentVid);
    const nextItem = playlist[idx + 1];
    if (nextItem) location.href = nextItem.url;
  }

  /* ====== 初始化 ====== */
  setInterval(() => {
    createButtonContainer();
    createQuoteSwitch();
    createPlaylistButton();
    createSpeedControlButton();
    scanVideoEntries();
    applySpeedToVideo();
  }, 2000);

})();