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.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube Focus Enhancer
// @namespace    http://tampermonkey.net/
// @version      1.5.2
// @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.
// @author       Choudhary
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(() => {
  'use strict';

  /********** CONFIGURATION **********/
  const config = {
    requiredPercentToUnlockComments: 50, // TEST: set to 100 to reproduce playlist-end behavior; set to 50 normally
    seekToleranceSeconds: 2,
    intersectionVisibilityThreshold: 0.5,
    smoothRevealMs: 350,
    // how many seconds before end to attempt pause (base). we will use a margin to attempt earlier
    pauseAtEndPlaylistTolerance: 0.8,
    // extra safety margin (seconds) to attempt earlier than tolerance to beat autoplay
    pauseMarginSeconds: 0.55,
    // frequently poll remaining time when guarding the end (small => more aggressive)
    endMonitorIntervalMs: 40,
    // after calling pause, how long (ms) we'll wait and re-check success (total timeout)
    pauseConfirmTimeoutMs: 900,
    // period to try re-attaching to video when initializing
    initRetryIntervalMs: 500,
    verbose: false
  };

  /********** UTILITIES **********/
  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(res => setTimeout(res, ms));

  /********** CSS inject (once) **********/
  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 (per-tab instance) **********/
  class YTGuard {
    constructor() {
      this.playerVideo = null;
      this.videoWrapper = null;
      this.currentWatchId = null;
      this.watchingState = null;

      // sets to avoid carryover and to ensure id-based ops
      this.unlockedVideoIds = new Set();        // video IDs that were unlocked in this tab
      this.pausedAtEndForIds = new Set();       // video IDs for which we already did end-pause handling

      this.endMonitorInterval = null;           // interval id used for end-monitor
      this.mutationObserver = null;
      this.intersectionObserver = null;

      this.bound = {};
      this.lastUrl = location.href;
      this.urlPoller = null;
      this.initialized = false;
      this.destroyed = false;

      // flag set if navigation started (yt-navigate-start detected)
      this._navigationStarted = false;

      // NEW: Flag to prevent auto-resume when we've paused at the end
      this._pausedAtEnd = false;

      log('constructor');
      this.setup();
    }

    setup() {
      ensureCSS();

      // Clean previous instance in same tab
      if (window.__ytSmartInstance && !window.__ytSmartInstance.destroyed) {
        window.__ytSmartInstance.destroy();
      }
      window.__ytSmartInstance = this;

      // bind handlers with stable references
      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);
      this.bound.ytNavStart = this.onYouTubeNavigateStart.bind(this);

      // global listeners
      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);

      // Listen for YouTube SPA navigation start event if available
      window.addEventListener('yt-navigate-start', this.bound.ytNavStart, true);
      // Also listen to our injected 'yt-smart-history' via history patch
      this.patchHistory();

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

      // Poll URL as backup for SPA changes
      this.urlPoller = setInterval(() => {
        if (location.href !== this.lastUrl) {
          log('URL change (poller)', this.lastUrl, '->', location.href);
          this.lastUrl = location.href;
          this.onUrlChange();
        }
      }, config.initRetryIntervalMs);

      // initial attach attempts
      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 < 120 && !this.destroyed; i++) {
        try {
          const v = this.findVideoElement();
          if (v) {
            log('found <video>');
            this.initForVideo(v);
            break;
          }
        } catch (e) { /* ignore */ }
        await sleep(config.initRetryIntervalMs);
      }
    }

    findVideoElement() {
      return document.querySelector('video.html5-main-video, video.video-stream, #movie_player video');
    }

    onBodyMutations() {
      if (this.destroyed) return;
      const v = this.findVideoElement();
      if (v && (!this.playerVideo || this.playerVideo !== v)) {
        log('MutationObserver -> new video element');
        this.initForVideo(v);
      }
      this.tryAttachHideClasses();
    }

    onHistoryChange() {
      if (this.destroyed) return;
      log('history popstate -> onUrlChange');
      this.onUrlChange();
    }

    onYouTubeNavigateStart() {
      // YouTube SPA emits this at the start of navigation in many builds
      log('yt-navigate-start detected');
      this._navigationStarted = true;

      // NEW: Clear unlockedVideoIds when navigation starts to prevent carry-over
      this.unlockedVideoIds.clear();
      log('cleared unlockedVideoIds due to navigation start');
    }

    onUrlChange() {
      log('onUrlChange invoked');
      // reset transient state & listeners for previous video
      this.cleanupVideoListeners();
      this.playerVideo = null;
      this.videoWrapper = null;
      this.currentWatchId = null;
      this.watchingState = null;
      this._navigationStarted = false;
      this._pausedAtEnd = false; // NEW: Reset end-pause flag
      this._stopEndMonitor();
      this.initialized = false;

      // NEW: Don't clear unlockedVideoIds here - only clear on navigation start
      // This allows same-page video changes to maintain unlock state

      // reattach cleanly
      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) { if (this.playerVideo) this.pauseByScript('visibility:hidden'); }
      else {
        if (this.playerVideo) {
          // NEW: Don't resume if we're paused at the end
          if (this._pausedAtEnd) {
            log('visibility change: ignoring resume because paused at end');
            return;
          }
          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('comments hidden initially');
      }
      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('sidebar hidden initially');
      }
    }

    initForVideo(videoEl) {
      if (this.destroyed) return;
      if (!videoEl) return;

      // if the same element is still attached and already initialized, skip
      if (this.playerVideo === videoEl && this.initialized) {
        log('initForVideo: already attached');
        return;
      }

      this.cleanupVideoListeners();

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

      // reset transient counters (prevents carryover)
      this.resetWatchingState();

      // NEW: Only reveal comments if this video was unlocked in THIS SESSION
      // and we're not in a playlist context where we want fresh start
      if (this.unlockedVideoIds.has(this.currentWatchId)) {
        log('this video was unlocked previously in this tab -> revealing now if same id');
        this._revealCommentsNowIfCurrent(this.currentWatchId);
      } else {
        // NEW: Ensure comments are hidden for new videos in playlist
        this._hideCommentsNow();
        log('new video detected, comments hidden');
      }

      // bind stable handlers for removal later
      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 complete, videoId:', this.currentWatchId);

      // if playlist + requires ~100%, start aggressive end monitor to attempt pause before autoplay
      if (this.isPlaylistActive() && config.requiredPercentToUnlockComments >= 99.5) {
        this._startEndMonitor(this.currentWatchId);
      } else {
        this._stopEndMonitor();
      }

      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;
    }

    /******** time / seek handlers ********/
    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;
      }

      // Normal unlock logic (for non-100% or early unlock)
      this.tryUnlockCommentsIfEligible(d);
      // End monitor handles playlist+100% aggressively; no extra here
    }

    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('forward skip detected -> organic=false');
      } else {
        log('small/backwards seek allowed');
      }
      this.watchingState.lastReportedCurrentTime = to;
      this.watchingState.lastSeekedFrom = null;
    }

    tryUnlockCommentsIfEligible(duration) {
      if (!this.playerVideo) return;
      if (!duration || !isFinite(duration) || duration <= 0) return;

      const requiredSeconds = (config.requiredPercentToUnlockComments / 100) * duration;
      const watched = this.watchingState.watchedOrganicSeconds;

      log('tryUnlockCommentsIfEligible', {watched, requiredSeconds, organic: this.watchingState.organic});

      if (watched >= requiredSeconds && this.watchingState.organic) {
        const vid = this.currentWatchId || this.getVideoIdFromUrl();
        if (!vid) return;
        // mark unlocked at id-level to avoid carry-over
        this.unlockedVideoIds.add(vid);
        this.watchingState.unlocked = true;
        // reveal now only if still on same id
        this._revealCommentsNowIfCurrent(vid);
        log('marked unlocked for id', vid);
      }
    }

    _revealCommentsNowIfCurrent(videoId) {
      const cur = this.getVideoIdFromUrl();
      if (cur && cur === videoId) {
        const comments = document.querySelector('ytd-comments#comments');
        if (comments) {
          comments.classList.remove('tm-hide-comments');
          comments.classList.add('tm-show-comments');
          log('comments revealed for id', videoId);
        }
      } else {
        log('reveal skipped because currentId != targetId', {cur, videoId});
      }
    }

    // NEW: Method to hide comments for new videos
    _hideCommentsNow() {
      const comments = document.querySelector('ytd-comments#comments');
      if (comments && !comments.classList.contains('tm-hide-comments')) {
        comments.classList.remove('tm-show-comments');
        comments.classList.add('tm-hide-comments');
        log('comments hidden for new video');
      }
    }

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

    /******** end-monitor (aggressive pre-end pause) ********/
    _startEndMonitor(videoId) {
      this._stopEndMonitor();
      log('start end monitor for', videoId);
      this.endMonitorInterval = setInterval(() => {
        this._checkEndGuard(videoId);
      }, config.endMonitorIntervalMs);
    }

    _stopEndMonitor() {
      if (this.endMonitorInterval) {
        clearInterval(this.endMonitorInterval);
        this.endMonitorInterval = null;
        log('stop end monitor');
      }
    }

    async _checkEndGuard(videoId) {
      if (this.destroyed) return;
      if (!this.playerVideo) return;
      const cur = this.getVideoIdFromUrl();
      if (!cur || cur !== videoId) { this._stopEndMonitor(); return; }
      const d = this.playerVideo.duration || 0;
      if (!d || !isFinite(d) || d <= 0) return;
      const remaining = d - this.playerVideo.currentTime;
      // use margin to attempt earlier than base tolerance
      const triggerAt = Math.max(0, config.pauseAtEndPlaylistTolerance + config.pauseMarginSeconds);
      if (remaining <= triggerAt && !this.pausedAtEndForIds.has(videoId)) {
        log('end guard triggered', {videoId, remaining, triggerAt});
        this.pausedAtEndForIds.add(videoId);
        await this._attemptPauseAndConfirmUnlock(videoId);
      }
    }

    async _attemptPauseAndConfirmUnlock(videoId) {
      if (!this.playerVideo) return;
      // mark navigation flag as false until we detect it
      this._navigationStarted = false;

      // NEW: Set the paused-at-end flag to prevent auto-resume
      this._pausedAtEnd = true;

      // Try to pause immediately and then poll to confirm paused & same video
      try {
        try { this.playerVideo.pause(); } catch (e) { log('pause() threw', e); }
      } catch (e) { log('pause attempt error', e); }

      const start = Date.now();
      let confirmed = false;
      while (Date.now() - start < config.pauseConfirmTimeoutMs) {
        // if a navigation started, abort
        if (this._navigationStarted) {
          log('navigation started during pauseConfirm -> abort unlock for', videoId);
          break;
        }
        // if video element replaced or id changed, abort
        const cur = this.getVideoIdFromUrl();
        if (!cur || cur !== videoId) {
          log('video id changed during pauseConfirm -> abort', {cur, videoId});
          break;
        }
        // check paused state
        if (this.playerVideo.paused) {
          confirmed = true;
          break;
        }
        // else wait briefly and re-check
        await sleep(40);
      }

      if (confirmed) {
        // treat as fully watched: mark watched time and unlock for that id
        const d = this.playerVideo.duration || 0;
        this.watchingState.watchedOrganicSeconds = d;
        this.watchingState.unlocked = true;
        this.watchingState.autoPausedByScript = true;
        this.unlockedVideoIds.add(videoId);
        // reveal only if still on same id
        this._revealCommentsNowIfCurrent(videoId);
        log('Pause confirmed & unlocked for', videoId);
      } else {
        // abort: ensure we do not keep unlocked state for this id (to avoid carry-over)
        this.unlockedVideoIds.delete(videoId);
        // NEW: Reset the paused-at-end flag if we failed
        this._pausedAtEnd = false;
        log('Pause NOT confirmed - abort unlocking for', videoId);
      }

      // stop end monitor
      this._stopEndMonitor();
    }

    // legacy stub kept for parity
    tryPauseAtEndIfPlaylist() { return; }

    /******** event handlers: pause/play/ended ********/
    onPauseEvent() {
      if (this.watchingState.autoPausedByScript) log('pause by script');
      else { this.watchingState.userPaused = true; log('pause by user'); }
    }

    onPlayEvent() {
      this.watchingState.userPaused = false;
      this.watchingState.autoPausedByScript = false;
      // NEW: Reset paused-at-end flag when user manually plays
      this._pausedAtEnd = false;
      log('play event');
    }

    onEndedEvent() {
      // fallback: ended may be fired if we missed pre-end guard
      if (!this.playerVideo) return;
      const d = this.playerVideo.duration || 0;
      this.watchingState.watchedOrganicSeconds = d;
      const vid = this.currentWatchId || this.getVideoIdFromUrl();
      if (vid) {
        // only mark as unlocked for this id, reveal only if still on same id
        this.unlockedVideoIds.add(vid);
        this._revealCommentsNowIfCurrent(vid);
        log('ended fallback: unlocked for', vid);
      }

      // fallback pause attempt if playlist+100% and we didn't yet handle
      if (this.isPlaylistActive() && config.requiredPercentToUnlockComments >= 99.5 && vid && !this.pausedAtEndForIds.has(vid)) {
        try {
          this.playerVideo.pause();
          this.watchingState.autoPausedByScript = true;
          // NEW: Set paused-at-end flag for fallback case too
          this._pausedAtEnd = true;
          this.pausedAtEndForIds.add(vid);
          this._revealCommentsNowIfCurrent(vid);
          log('ended fallback: paused & revealed for', vid);
        } catch (e) {
          log('ended fallback pause failed', e);
        }
      }

      // stop monitor (if any)
      this._stopEndMonitor();
      log('ended event processed');
    }

    /******** pause/resume helpers ********/
    pauseByScript(reason) {
      if (!this.playerVideo) return;
      try {
        if (!this.playerVideo.paused) {
          this.playerVideo.pause();
          this.watchingState.autoPausedByScript = true;
          log('pauseByScript', reason);
        }
      } catch (e) { log('pauseByScript err', e); }
    }

    async resumeIfAllowed(reason) {
      if (!this.playerVideo) return;

      // NEW: Don't resume if we're paused at the end
      if (this._pausedAtEnd) {
        log('resume skip: paused at end', reason);
        return;
      }

      if (!this.watchingState.autoPausedByScript) { log('resume skip: not autoPaused'); return; }
      if (this.watchingState.userPaused) { log('resume skip: userPaused'); return; }
      try {
        await this.playerVideo.play();
        this.watchingState.autoPausedByScript = false;
        log('resumeIfAllowed', reason);
      } catch (e) { log('resume play() failed', 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;

      // NEW: Don't resume if we're paused at the end
      if (this._pausedAtEnd) {
        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('Intersection wrapper not found'); return; }
        this.intersectionObserver = new IntersectionObserver((entries) => {
          entries.forEach(entry => {
            const ratio = entry.intersectionRatio;
            log('intersection ratio', ratio);

            // NEW: Don't resume if we're paused at the end
            if (ratio < config.intersectionVisibilityThreshold) {
              this.pauseByScript('intersection:out-of-view');
            } else if (!this._pausedAtEnd) {
              this.resumeIfAllowed('intersection:back-in-view');
            } else {
              log('intersection: ignoring resume because paused at end');
            }
          });
        }, { threshold: [0, config.intersectionVisibilityThreshold, 1.0] });
        this.intersectionObserver.observe(videoWrapper);
      } catch (e) { log('intersection error', e); }
    }

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

    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);
        window.removeEventListener('yt-navigate-start', this.bound.ytNavStart, true);
        document.removeEventListener('mousemove', this.bound.pointer, { passive: true });
        document.removeEventListener('click', this.bound.click, true);
        this.cleanupVideoListeners();
      } catch (e) {}
      if (window.__ytSmartInstance === this) window.__ytSmartInstance = null;
    }
  } // end class

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

})();