您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
// ==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); } })();