Greasy Fork

Greasy Fork is available in English.

小叶的视频“跳到结尾”助手

默认自动把页面所有视频跳到结尾前 N 秒;支持自动播放(含静音降级),UI 可快捷键呼出;仅顶层页面显示 UI,防重复。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         小叶的视频“跳到结尾”助手
// @namespace    https://github.com/YiPort
// @version      1.2.0.1
// @description  默认自动把页面所有视频跳到结尾前 N 秒;支持自动播放(含静音降级),UI 可快捷键呼出;仅顶层页面显示 UI,防重复。
// @author       小叶
// @match        *://*/*
// @license      MIT
// @icon         https://img20.360buyimg.com/openfeedback/jfs/t1/343217/38/22253/32013/690a29b2F838c0b8d/a3269ed44242b2ff.png
// @grant        GM_setValue
// @grant        GM_getValue
// ==/UserScript==

(function () {
  'use strict';

  // —— 全局单例防重 ——(同一窗口只初始化一次)
  if (window.__XIAOYE_SEEKEND_INIT__) return;
  window.__XIAOYE_SEEKEND_INIT__ = true;

  // —— 只在顶层窗口显示 UI;iframe 中不创建 UI ——
  const IS_TOP = window.top === window.self;

  const CONFIG = {
    STYLE: {
      COLORS: { PRIMARY: '#00A1D6', ACCENT: '#FF8C00' },
      RADIUS: { S: '4px', M: '8px' },
      TRANS: 'all 0.3s ease'
    },
    LAYOUT: { TRIGGER_W: { DEFAULT: '40px', EXPANDED: '80px' } },
    BEHAVIOR: {
      DEFAULT_OFFSET_SEC: 3,
      AUTO_SEEK_ON_LOAD: true,   // 默认自动跳尾
      AUTO_PLAY_ON_SEEK: true,   // ★ 新增:默认自动播放
      SHOW_UI_DEFAULT: false     // 默认不显示 UI(Alt+U 呼出)
    }
  };

  // 存取封装
  const store = {
    get(k, d) {
      try { return typeof GM_getValue === 'function' ? GM_getValue(k, d) : JSON.parse(localStorage.getItem(k)) ?? d; }
      catch { return d; }
    },
    set(k, v) {
      try {
        if (typeof GM_setValue === 'function') GM_setValue(k, v);
        else localStorage.setItem(k, JSON.stringify(v));
      } catch {}
    }
  };

  // 运行时状态
  const STATE = {
    offsetSec: store.get('seek_offset_sec', CONFIG.BEHAVIOR.DEFAULT_OFFSET_SEC),
    autoSeek: store.get('seek_auto', CONFIG.BEHAVIOR.AUTO_SEEK_ON_LOAD),
    autoPlay: store.get('seek_autoplay', CONFIG.BEHAVIOR.AUTO_PLAY_ON_SEEK), // ★ 新增
    uiOpen: false,
  };

  const ids = { trigger: 'seek-end-trigger', panel: 'seek-end-panel' };

  function log(...a){ console.log('[跳尾]', ...a); }

  function flashOnVideo(video, text) {
    const host = video.parentElement || video;
    const tag = document.createElement('div');
    tag.textContent = text;
    Object.assign(tag.style, {
      position: 'absolute',
      top: '10px',
      left: '10px',
      padding: '6px 10px',
      background: 'rgba(0,0,0,0.7)',
      color: '#fff',
      fontSize: '14px',
      fontWeight: 'bold',
      borderRadius: CONFIG.STYLE.RADIUS.S,
      zIndex: 999999
    });
    const oldPos = host.style.position;
    if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
    host.appendChild(tag);
    setTimeout(() => {
      tag.remove();
      if (!oldPos) host.style.position = '';
    }, 2000);
  }

  // ★ 新增:自动播放(含静音降级)
  async function ensurePlayback(video) {
    if (!video) return;
    try {
      if (video.paused) {
        // 优先不静音播放
        await video.play();
      }
    } catch (e1) {
      try {
        // 部分站点策略需要静音才允许自动播放
        video.muted = true;
        video.setAttribute('muted', '');
        await video.play();
        log('自动播放:已静音降级');
      } catch (e2) {
        log('自动播放失败(可能被策略拦截)');
      }
    }
  }

  function seekVideoToEnd(video, offsetSec) {
    if (!video) return;
    const doSeek = async () => {
      const d = Number(video.duration);
      if (Number.isFinite(d) && d > 0) {
        const t = Math.max(0, d - Math.max(0, offsetSec));
        if (!Number.isFinite(video.currentTime) || d - video.currentTime > 0.6) {
          video.currentTime = t;
          video.dispatchEvent(new Event('timeupdate'));
          flashOnVideo(video, offsetSec ? `已跳至结尾 (-${offsetSec}s)` : '已跳至结尾');
          log('seek =>', t.toFixed(2), '/', d.toFixed(2));
        }
        // ★ 跳尾后自动播放
        if (STATE.autoPlay) ensurePlayback(video);
      }
    };
    if (Number.isFinite(video.duration) && video.duration > 0) doSeek();
    else video.addEventListener('loadedmetadata', doSeek, { once: true });
  }

  function seekAllVideos(offset = STATE.offsetSec) {
    const vs = document.querySelectorAll('video');
    if (!vs.length) return;
    vs.forEach(v => seekVideoToEnd(v, offset));
  }

  // ========== UI(仅顶层窗口创建) ==========
  function ensureTrigger() {
    if (!IS_TOP) return;
    if (document.getElementById(ids.trigger)) return;
    const el = document.createElement('div');
    el.id = ids.trigger;
    el.textContent = '跳尾';
    el.style.cssText = `
      position: fixed; right: 0; top: 25%; transform: translateY(-50%);
      z-index: 999999; border: 1px solid ${CONFIG.STYLE.COLORS.ACCENT};
      border-radius: ${CONFIG.STYLE.RADIUS.M};
      background: rgba(255,255,255,0.95);
      width: ${CONFIG.LAYOUT.TRIGGER_W.DEFAULT};
      padding: 8px 6px; text-align: center;
      color: ${CONFIG.STYLE.COLORS.ACCENT};
      font-size: 14px; cursor: pointer; user-select: none;
      transition: ${CONFIG.STYLE.TRANS};
    `;
    el.addEventListener('click', togglePanel);
    el.onmouseenter = () => { el.style.width = CONFIG.LAYOUT.TRIGGER_W.EXPANDED; };
    el.onmouseleave = () => { if (!STATE.uiOpen) el.style.width = CONFIG.LAYOUT.TRIGGER_W.DEFAULT; };
    document.body.appendChild(el);
  }

  function togglePanel() {
    STATE.uiOpen = !STATE.uiOpen;
    if (STATE.uiOpen) {
      createPanel();
      const t = document.getElementById(ids.trigger);
      if (t) t.style.width = CONFIG.LAYOUT.TRIGGER_W.EXPANDED;
    } else {
      const t = document.getElementById(ids.trigger);
      if (t) t.style.width = CONFIG.LAYOUT.TRIGGER_W.DEFAULT;
      const p = document.getElementById(ids.panel);
      p && p.remove();
    }
  }

  function createPanel() {
    if (!IS_TOP) return;
    if (document.getElementById(ids.panel)) return;
    const box = document.createElement('div');
    box.id = ids.panel;
    box.style.cssText = `
      position: fixed; right: 80px; top: 25%; transform: translateY(-50%);
      z-index: 999999; width: 260px; background: rgba(255,255,255,0.98);
      border: 1px solid ${CONFIG.STYLE.COLORS.ACCENT};
      border-radius: ${CONFIG.STYLE.RADIUS.M};
      box-shadow: 0 4px 12px rgba(0,0,0,0.15);
      padding: 10px 12px 12px; color: #444; font-size: 14px;
    `;
    const title = document.createElement('div');
    title.textContent = '视频跳到结尾';
    title.style.cssText = `font-weight: bold; color: ${CONFIG.STYLE.COLORS.ACCENT}; margin-bottom: 8px;`;

    // 偏移行
    const row = document.createElement('div');
    row.style.cssText = 'display:flex; align-items:center; gap:6px; margin-bottom:8px;';
    const label = document.createElement('label'); label.textContent = '结尾偏移(秒):';
    const input = document.createElement('input');
    Object.assign(input, { type: 'number', min: '0', step: '1', value: String(STATE.offsetSec) });
    input.style.cssText = 'width:90px; text-align:center; border:1px solid #ccc; border-radius:4px; padding:2px 4px;';

    // 自动跳尾
    const autoSeekWrap = document.createElement('label');
    autoSeekWrap.style.cssText = 'display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:8px;';
    const autoSeekChk = document.createElement('input'); autoSeekChk.type = 'checkbox'; autoSeekChk.checked = !!STATE.autoSeek;
    const autoSeekTxt = document.createElement('span'); autoSeekTxt.textContent = '新视频自动跳尾';

    // ★ 自动播放
    const autoPlayWrap = document.createElement('label');
    autoPlayWrap.style.cssText = 'display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:8px;';
    const autoPlayChk = document.createElement('input'); autoPlayChk.type = 'checkbox'; autoPlayChk.checked = !!STATE.autoPlay;
    const autoPlayTxt = document.createElement('span'); autoPlayTxt.textContent = '跳尾后自动播放(失败将静音重试)';

    // 按钮区
    const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex; gap:8px; justify-content:flex-end;';
    const applyBtn = document.createElement('button');
    applyBtn.textContent = '本页全部跳尾';
    applyBtn.style.cssText = `background:${CONFIG.STYLE.COLORS.PRIMARY}; color:#fff; border:none; padding:6px 10px; border-radius:6px; cursor:pointer;`;
    const closeBtn = document.createElement('button');
    closeBtn.textContent = '关闭';
    closeBtn.style.cssText = `background:#bbb; color:#fff; border:none; padding:6px 10px; border-radius:6px; cursor:pointer;`;

    applyBtn.onclick = () => {
      STATE.offsetSec = Math.max(0, parseInt(input.value || '0', 10));
      store.set('seek_offset_sec', STATE.offsetSec);
      seekAllVideos();
    };
    autoSeekChk.onchange = () => { STATE.autoSeek = !!autoSeekChk.checked; store.set('seek_auto', STATE.autoSeek); };
    autoPlayChk.onchange = () => { STATE.autoPlay = !!autoPlayChk.checked; store.set('seek_autoplay', STATE.autoPlay); }; // ★
    closeBtn.onclick = togglePanel;

    const tip = document.createElement('div');
    tip.style.cssText = 'margin-top:8px; color:#888; font-size:12px; white-space:pre-line;';
    tip.textContent = '快捷键:\nAlt+L  立即跳尾\nAlt+S  切换自动跳尾\nAlt+P  切换自动播放\nAlt+= / Alt+-  偏移±1s\nAlt+0  偏移=0\nAlt+U  显示/隐藏面板';

    row.appendChild(label); row.appendChild(input);
    autoSeekWrap.appendChild(autoSeekChk); autoSeekWrap.appendChild(autoSeekTxt);
    autoPlayWrap.appendChild(autoPlayChk); autoPlayWrap.appendChild(autoPlayTxt);
    btnRow.appendChild(applyBtn); btnRow.appendChild(closeBtn);

    box.appendChild(title);
    box.appendChild(row);
    box.appendChild(autoSeekWrap);
    box.appendChild(autoPlayWrap); // ★
    box.appendChild(btnRow);
    box.appendChild(tip);
    document.body.appendChild(box);
  }

  // ========== 键盘快捷键 ==========
  window.addEventListener('keydown', (e) => {
    const tag = (document.activeElement?.tagName || '').toLowerCase();
    if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return;
    if (!(e.altKey && !e.ctrlKey && !e.metaKey)) return;

    if (e.key === 'l' || e.key === 'L') { e.preventDefault(); seekAllVideos(); }
    else if (e.key === 's' || e.key === 'S') {
      e.preventDefault();
      STATE.autoSeek = !STATE.autoSeek;
      store.set('seek_auto', STATE.autoSeek);
      log('自动跳尾:', STATE.autoSeek ? '开启' : '关闭');
      if (STATE.autoSeek) seekAllVideos();
    }
    else if (e.key === 'p' || e.key === 'P') { // ★ 切换自动播放
      e.preventDefault();
      STATE.autoPlay = !STATE.autoPlay;
      store.set('seek_autoplay', STATE.autoPlay);
      log('自动播放:', STATE.autoPlay ? '开启' : '关闭');
    }
    else if (e.key === '=' || e.key === '+') { e.preventDefault(); STATE.offsetSec = Math.max(0, (STATE.offsetSec|0) + 1); store.set('seek_offset_sec', STATE.offsetSec); log('偏移秒数:', STATE.offsetSec); }
    else if (e.key === '-') { e.preventDefault(); STATE.offsetSec = Math.max(0, (STATE.offsetSec|0) - 1); store.set('seek_offset_sec', STATE.offsetSec); log('偏移秒数:', STATE.offsetSec); }
    else if (e.key === '0') { e.preventDefault(); STATE.offsetSec = 0; store.set('seek_offset_sec', STATE.offsetSec); log('偏移秒数:0'); }
    else if (e.key === 'u' || e.key === 'U') {
      e.preventDefault();
      if (!IS_TOP) return; // iframe 不显示 UI
      const t = document.getElementById(ids.trigger);
      if (t) { t.remove(); const p = document.getElementById(ids.panel); p && p.remove(); STATE.uiOpen = false; }
      else { ensureTrigger(); }
    }
  });

  // ========== 观察新视频 ==========
  const mo = new MutationObserver((muts) => {
    if (!STATE.autoSeek) return;
    for (const m of muts) {
      if (m.type === 'childList') {
        m.addedNodes.forEach((node) => {
          if (node && node.nodeType === 1) {
            if (node.tagName === 'VIDEO') {
              seekVideoToEnd(node, STATE.offsetSec);
            } else {
              const vs = node.querySelectorAll?.('video');
              vs && vs.forEach(v => seekVideoToEnd(v, STATE.offsetSec));
            }
          }
        });
      }
    }
  });

  function init() {
    if (STATE.autoSeek) seekAllVideos();                  // 默认自动跳尾(进页即生效)
    if (IS_TOP && CONFIG.BEHAVIOR.SHOW_UI_DEFAULT) ensureTrigger(); // UI 默认不显示
    mo.observe(document.body || document.documentElement, { childList: true, subtree: true });

    // 兜底轮询(单例)
    let pollId = window.__XIAOYE_SEEKEND_POLL__;
    if (!pollId) {
      pollId = setInterval(() => { if (STATE.autoSeek) seekAllVideos(); }, 5000);
      window.__XIAOYE_SEEKEND_POLL__ = pollId;
    }
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
  else init();
})();