YouTube Focus Enhancer

Hides comments and recommendations until you watch a configurable percentage of the video without skipping; automatically pauses when the player moves out of view, switches between tabs, or clicks away, and automatically resumes the video when you return.

目前為 2025-09-24 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube Focus Enhancer
// @namespace    http://tampermonkey.net/
// @version      1.2.1
// @description  Hides comments and recommendations until you watch a configurable percentage of the video without skipping; automatically pauses when the player moves out of view, switches between tabs, or clicks away, and automatically resumes the video when you return.
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(() => {
  'use strict';

  /*** CONFIGURATION ***/
  const config = {
    requiredPercentToUnlockComments: 50, // Prozent (Standard 50). Setzen Sie 100 falls gewünscht.
    seekToleranceSeconds: 2, // wenn vorwärts gesprungen > diese Sek., gilt als "geskippt"
    intersectionVisibilityThreshold: 0.5, // wieviel vom Player sichtbar sein muss, sonst Pause (0..1)
    smoothRevealMs: 400, // Dauer der Fade-In Animation beim Freischalten (ms)
    pauseAtEndPlaylistTolerance: 0.5, // Sekunden vor Ende, wo wir bei Playlists anhalten können
    initRetryIntervalMs: 800, // interval um video wrapper zu finden
    verbose: false // true -> console.debugs
  };

  /*** UTILITY ***/
  const INSTANCE_ID = Math.random().toString(36).slice(2, 9);
  const log = (...args) => { if (config.verbose) console.debug(`[YT-SMART ${INSTANCE_ID}]`, ...args); };
  const sleep = ms => new Promise(r => setTimeout(r, ms));

  /*** ADD STYLES (once per page) ***/
  function ensureCSS() {
    if (document.head.querySelector('style[data-yt-smart]')) return;
    const css = `
    ytd-comments#comments, ytd-item-section-renderer#related, ytd-watch-next-secondary-results-renderer {
      transition: opacity ${config.smoothRevealMs}ms ease, max-height ${config.smoothRevealMs}ms ease;
    }
    .tm-hide-comments {
      opacity: 0 !important;
      max-height: 0 !important;
      overflow: hidden !important;
      pointer-events: none !important;
    }
    .tm-hide-related {
      opacity: 0 !important;
      max-height: 0 !important;
      overflow: hidden !important;
      pointer-events: none !important;
    }
    .tm-show-comments {
      opacity: 1 !important;
      max-height: 2000px !important;
      pointer-events: auto !important;
    }`;
    const s = document.createElement('style');
    s.setAttribute('data-yt-smart', INSTANCE_ID);
    s.textContent = css;
    document.head.appendChild(s);
    log('CSS injected');
  }

  /*** MAIN CLASS - ENCAPSULATES PER-PAGE INSTANCE ***/
  class YTGuard {
    constructor() {
      this.playerVideo = null;
      this.videoWrapper = null;
      this.currentWatchId = null;
      this.watchingState = null;
      this.intersectionObserver = null;
      this.mutationObserver = null;
      this.bound = {}; // bound handlers for removal
      this.lastUrl = location.href;
      this.urlPoller = null;
      this.initialized = false;
      this.destroyed = false;
      log('YTGuard ctor');
      this.setup();
    }

    setup() {
      ensureCSS();
      if (window.__ytSmartInstance && !window.__ytSmartInstance.destroyed) {
        log('Vorherige Instanz vorhanden — bereinigen');
        window.__ytSmartInstance.destroy();
      }
      window.__ytSmartInstance = this;

      this.bound.visibility = this.onVisibilityChange.bind(this);
      this.bound.focus = this.onWindowFocus.bind(this);
      this.bound.blur = this.onWindowBlur.bind(this);
      this.bound.pageshow = this.onPageShow.bind(this);
      this.bound.mutation = this.onBodyMutations.bind(this);
      this.bound.popstate = this.onHistoryChange.bind(this);
      this.bound.pointer = this.onMouseMove.bind(this);
      this.bound.click = this.onDocumentClick.bind(this);

      document.addEventListener('visibilitychange', this.bound.visibility, true);
      window.addEventListener('focus', this.bound.focus, true);
      window.addEventListener('blur', this.bound.blur, true);
      window.addEventListener('pageshow', this.bound.pageshow);
      window.addEventListener('popstate', this.bound.popstate);
      document.addEventListener('mousemove', this.bound.pointer, { passive: true });
      document.addEventListener('click', this.bound.click, true);

      this.patchHistory();

      this.mutationObserver = new MutationObserver(this.bound.mutation);
      try {
        this.mutationObserver.observe(document.documentElement || document.body, { childList: true, subtree: true });
      } catch (e) { /* ignore */ }

      this.urlPoller = setInterval(() => {
        if (location.href !== this.lastUrl) {
          log('URL change detected by poller', this.lastUrl, '->', location.href);
          this.lastUrl = location.href;
          this.onUrlChange();
        }
      }, config.initRetryIntervalMs);

      this.tryAttachLoop();
    }

    patchHistory() {
      try {
        const push = history.pushState;
        const replace = history.replaceState;
        history.pushState = function () {
          const r = push.apply(this, arguments);
          window.dispatchEvent(new Event('yt-smart-history'));
          return r;
        };
        history.replaceState = function () {
          const r = replace.apply(this, arguments);
          window.dispatchEvent(new Event('yt-smart-history'));
          return r;
        };
        window.addEventListener('yt-smart-history', () => {
          log('history API change -> onUrlChange');
          this.onUrlChange();
        });
      } catch (e) {
        log('history patch failed', e);
      }
    }

    async tryAttachLoop() {
      for (let i = 0; !this.initialized && i < 50 && !this.destroyed; i++) {
        try {
          const v = this.findVideoElement();
          if (v) {
            log('Video gefunden in tryAttachLoop');
            this.initForVideo(v);
            break;
          }
        } catch (e) { /* ignore */ }
        await sleep(config.initRetryIntervalMs);
      }
    }

    findVideoElement() {
      const v = document.querySelector('video.html5-main-video, video.video-stream, #movie_player video');
      return v || null;
    }

    onBodyMutations(muts) {
      if (this.destroyed) return;
      const v = this.findVideoElement();
      if (v && (!this.playerVideo || this.playerVideo !== v)) {
        log('MutationObserver detected Video change/appearance');
        this.initForVideo(v);
      }
      this.tryAttachHideClasses();
    }

    onHistoryChange() {
      if (this.destroyed) return;
      this.onUrlChange();
    }

    onUrlChange() {
      log('onUrlChange called');
      this.cleanupVideoListeners();
      this.playerVideo = null;
      this.videoWrapper = null;
      this.initialized = false;
      this.currentWatchId = null;
      this.watchingState = null;
      this.tryAttachLoop();
      this.tryAttachHideClasses();
    }

    onPageShow() {
      log('pageshow');
      this.tryAttachLoop();
    }

    onWindowFocus() {
      log('window focus');
      this.tryAttachLoop();
      if (this.playerVideo) this.resumeIfAllowed('window:focus');
    }

    onWindowBlur() {
      log('window blur');
      if (this.playerVideo) this.pauseByScript('window:blur');
    }

    onVisibilityChange() {
      if (document.hidden) {
        log('document hidden');
        if (this.playerVideo) this.pauseByScript('visibility:hidden');
      } else {
        log('document visible');
        if (this.playerVideo) this.resumeIfAllowed('visibility:visible');
      }
    }

    tryAttachHideClasses() {
      const comments = document.querySelector('ytd-comments#comments');
      if (comments && !comments.classList.contains('tm-show-comments') && !comments.classList.contains('tm-hide-comments')) {
        comments.classList.add('tm-hide-comments');
        log('Kommentare initial versteckt');
      }
      const related = document.querySelector('ytd-watch-next-secondary-results-renderer, ytd-item-section-renderer#related');
      if (related && !related.classList.contains('tm-hide-related')) {
        related.classList.add('tm-hide-related');
        log('Seitenleiste initial versteckt');
      }
    }

    initForVideo(videoEl) {
      if (this.destroyed) return;
      if (!videoEl) return;
      if (this.playerVideo === videoEl && this.initialized) {
        log('initForVideo: gleiche Video-Element bereits angebunden');
        return;
      }
      this.cleanupVideoListeners();

      this.playerVideo = videoEl;
      this.videoWrapper = document.querySelector('.html5-video-player') || document.querySelector('#movie_player') || this.playerVideo.closest('.html5-video-player') || null;
      this.currentWatchId = this.getVideoIdFromUrl() || `${Date.now()}`;
      this.resetWatchingState();

      this.bound.timeupdate = this.onTimeUpdate.bind(this);
      this.bound.seeking = this.onSeeking.bind(this);
      this.bound.seeked = this.onSeeked.bind(this);
      this.bound.pause = this.onPauseEvent.bind(this);
      this.bound.play = this.onPlayEvent.bind(this);
      this.bound.ended = this.onEndedEvent.bind(this);

      this.playerVideo.addEventListener('timeupdate', this.bound.timeupdate);
      this.playerVideo.addEventListener('seeking', this.bound.seeking);
      this.playerVideo.addEventListener('seeked', this.bound.seeked);
      this.playerVideo.addEventListener('pause', this.bound.pause);
      this.playerVideo.addEventListener('play', this.bound.play);
      this.playerVideo.addEventListener('ended', this.bound.ended);

      this.setupIntersectionObserver();

      this.initialized = true;
      log('initForVideo completed — instance:', INSTANCE_ID, 'videoId:', this.currentWatchId);

      this.tryAttachHideClasses();
    }

    resetWatchingState() {
      this.watchingState = {
        organic: true,
        watchedOrganicSeconds: 0,
        lastTimeSeen: this.playerVideo ? this.playerVideo.currentTime : 0,
        lastReportedCurrentTime: this.playerVideo ? this.playerVideo.currentTime : 0,
        lastSeekedFrom: null,
        unlocked: false,
        autoPausedByScript: false,
        userPaused: false
      };
    }

    getVideoIdFromUrl() {
      const m = location.search.match(/[?&]v=([^&]+)/);
      return m ? decodeURIComponent(m[1]) : null;
    }

    onTimeUpdate() {
      if (!this.playerVideo || this.destroyed) return;
      const t = this.playerVideo.currentTime;
      const d = this.playerVideo.duration || 0;
      const last = this.watchingState.lastReportedCurrentTime || t;
      if (t >= last && !this.playerVideo.seeking) {
        const delta = t - last;
        if (this.watchingState.organic && delta > 0 && delta < 10) {
          this.watchingState.watchedOrganicSeconds += delta;
        }
        this.watchingState.lastReportedCurrentTime = t;
        this.watchingState.lastTimeSeen = t;
      } else {
        this.watchingState.lastReportedCurrentTime = t;
        this.watchingState.lastTimeSeen = t;
      }
      this.tryUnlockCommentsIfEligible(d);
      this.tryPauseAtEndIfPlaylist(d, t);
    }

    onSeeking() {
      if (!this.playerVideo) return;
      this.watchingState.lastSeekedFrom = this.watchingState.lastReportedCurrentTime || this.playerVideo.currentTime;
      log('seeking from', this.watchingState.lastSeekedFrom);
    }

    onSeeked() {
      if (!this.playerVideo) return;
      const from = this.watchingState.lastSeekedFrom != null ? this.watchingState.lastSeekedFrom : this.watchingState.lastReportedCurrentTime;
      const to = this.playerVideo.currentTime;
      const delta = to - from;
      log('seeked', { from, to, delta });
      if (delta > config.seekToleranceSeconds) {
        this.watchingState.organic = false;
        this.watchingState.watchedOrganicSeconds = 0;
        log('Detected forward skip -> organic=false; watched reset');
      } else {
        log('Seek small/backwards -> allowed');
      }
      this.watchingState.lastReportedCurrentTime = to;
      this.watchingState.lastSeekedFrom = null;
    }

    tryUnlockCommentsIfEligible(duration) {
      if (!this.playerVideo || this.watchingState.unlocked) return;
      if (!duration || !isFinite(duration) || duration <= 0) return;
      const requiredSeconds = (config.requiredPercentToUnlockComments / 100) * duration;
      log('watchedOrganicSeconds', this.watchingState.watchedOrganicSeconds, 'requiredSeconds', requiredSeconds);
      if (this.watchingState.watchedOrganicSeconds >= requiredSeconds && this.watchingState.organic) {
        this.unlockCommentsSmooth();
        this.watchingState.unlocked = true;
      }
    }

    unlockCommentsSmooth() {
      const comments = document.querySelector('ytd-comments#comments');
      if (!comments) {
        log('Kommentare DOM nicht gefunden zum Freischalten.');
        return;
      }
      comments.classList.remove('tm-hide-comments');
      comments.classList.add('tm-show-comments');
      log('Kommentare freigeschaltet (smooth)');
    }

    isPlaylistActive() {
      try {
        if (location.search && location.search.includes('list=')) return true;
        if (document.querySelector('ytd-playlist-panel-renderer')) return true;
      } catch (e) {}
      return false;
    }

    tryPauseAtEndIfPlaylist(duration, currentTime) {
      if (!this.isPlaylistActive()) return;
      if (config.requiredPercentToUnlockComments < 100) return;
      if (!this.playerVideo || !duration || !isFinite(duration)) return;
      if (duration - currentTime <= config.pauseAtEndPlaylistTolerance) {
        try {
          this.playerVideo.pause();
          this.watchingState.autoPausedByScript = true;
          this.watchingState.watchedOrganicSeconds = duration;
          this.watchingState.unlocked = true;
          this.unlockCommentsSmooth();
          log('Playlist detected: am Ende - Video pausiert und Kommentare freigegeben.');
        } catch (e) {
          log('Fehler beim Pausieren am Ende der Playlist', e);
        }
      }
    }

    onPauseEvent() {
      if (this.watchingState.autoPausedByScript) {
        log('pause event: von Skript ausgelöst');
      } else {
        this.watchingState.userPaused = true;
        log('pause event: vom Benutzer');
      }
    }

    onPlayEvent() {
      this.watchingState.userPaused = false;
      this.watchingState.autoPausedByScript = false;
      log('play event');
    }

    onEndedEvent() {
      const d = this.playerVideo.duration || 0;
      this.watchingState.watchedOrganicSeconds = d;
      this.tryUnlockCommentsIfEligible(d);
      log('ended event');
    }

    pauseByScript(reason) {
      if (!this.playerVideo) return;
      try {
        if (!this.playerVideo.paused) {
          this.playerVideo.pause();
          this.watchingState.autoPausedByScript = true;
          log('Video pausiert (script). Grund:', reason);
        }
      } catch (e) {
        log('Fehler beim Pausieren durch Script', e);
      }
    }

    async resumeIfAllowed(reason) {
      if (!this.playerVideo) return;
      if (!this.watchingState.autoPausedByScript) {
        log('resumeIfAllowed: skipping, not auto-paused by script. Grund:', reason);
        return;
      }
      if (this.watchingState.userPaused) {
        log('resumeIfAllowed: skipping, Benutzer pausiert aktiv. Grund:', reason);
        return;
      }
      try {
        await this.playerVideo.play();
        this.watchingState.autoPausedByScript = false;
        log('Video automatisch fortgesetzt. Grund:', reason);
      } catch (e) {
        log('Fehler beim automatischen Fortsetzen (Autoplay-Policy?):', e);
      }
    }

    isClickInsidePlayer(evt) {
      const player = document.querySelector('.html5-video-player, #movie_player');
      if (!player) return false;
      return player.contains(evt.target);
    }

    onDocumentClick(evt) {
      const inside = this.isClickInsidePlayer(evt);
      if (!inside && this.playerVideo && !this.playerVideo.paused) {
        this.pauseByScript('click:outside-player');
      }
    }

    onMouseMove(evt) {
      if (!this.playerVideo) return;
      const player = document.querySelector('.html5-video-player, #movie_player');
      if (!player) return;
      const rect = player.getBoundingClientRect();
      const inside = evt.clientX >= rect.left && evt.clientX <= rect.right && evt.clientY >= rect.top && evt.clientY <= rect.bottom;
      if (inside) {
        this.resumeIfAllowed('pointer:enter-player');
      }
    }

    setupIntersectionObserver() {
      try {
        if (this.intersectionObserver) {
          this.intersectionObserver.disconnect();
          this.intersectionObserver = null;
        }
        const videoWrapper = this.videoWrapper || document.querySelector('.html5-video-player') || document.querySelector('#movie_player');
        if (!videoWrapper) {
          log('IntersectionObserver: Video-Wrapper noch nicht gefunden.');
          return;
        }
        this.intersectionObserver = new IntersectionObserver((entries) => {
          entries.forEach(entry => {
            const ratio = entry.intersectionRatio;
            log('Intersection ratio', ratio);
            if (ratio < config.intersectionVisibilityThreshold) {
              this.pauseByScript('intersection:out-of-view');
            } else {
              this.resumeIfAllowed('intersection:back-in-view');
            }
          });
        }, { threshold: [0, config.intersectionVisibilityThreshold, 1.0] });
        this.intersectionObserver.observe(videoWrapper);
        log('IntersectionObserver eingerichtet');
      } catch (e) {
        log('IntersectionObserver Fehler', e);
      }
    }

    cleanupVideoListeners() {
      try {
        if (!this.playerVideo) return;
        const evs = ['timeupdate', 'seeking', 'seeked', 'pause', 'play', 'ended'];
        evs.forEach(ev => {
          const h = this.bound[ev];
          if (h) {
            this.playerVideo.removeEventListener(ev, h);
          }
        });
      } catch (e) { /* ignore */ }
      try {
        if (this.intersectionObserver) {
          this.intersectionObserver.disconnect();
          this.intersectionObserver = null;
        }
      } catch (e) {}
    }

    destroy() {
      if (this.destroyed) return;
      this.destroyed = true;
      log('destroying instance');
      try {
        if (this.mutationObserver) this.mutationObserver.disconnect();
        if (this.intersectionObserver) this.intersectionObserver.disconnect();
        if (this.urlPoller) clearInterval(this.urlPoller);
        document.removeEventListener('visibilitychange', this.bound.visibility, true);
        window.removeEventListener('focus', this.bound.focus, true);
        window.removeEventListener('blur', this.bound.blur, true);
        window.removeEventListener('pageshow', this.bound.pageshow);
        window.removeEventListener('popstate', this.bound.popstate);
        document.removeEventListener('mousemove', this.bound.pointer, { passive: true });
        document.removeEventListener('click', this.bound.click, true);
        this.cleanupVideoListeners();
      } catch (e) { /* ignore */ }
      if (window.__ytSmartInstance === this) window.__ytSmartInstance = null;
    }
  } // end class

  /*** STARTUP ***/
  try {
    if (!window.__ytSmartInstance || window.__ytSmartInstance.destroyed) {
      new YTGuard();
      log('YTGuard started — instance id', INSTANCE_ID);
    } else {
      window.__ytSmartInstance.destroy();
      new YTGuard();
      log('Replaced existing instance with new instance', INSTANCE_ID);
    }
  } catch (e) {
    console.error('YT-SMART init error', e);
  }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址