Smart Auto Skip YouTube Ads (Enhanced+ 8.5.0 with Volume Control)

智能跳过/弱化 YouTube 广告:强化新倒计时检测、按钮重试、速率钉住、失败兜底时间恢复刷新,并添加音量恢复功能

// ==UserScript==
// @name         Smart Auto Skip YouTube Ads (Enhanced+ 8.5.0 with Volume Control)
// @namespace    https://github.com/tientq64/userscripts
// @version      8.7.0
// @description  智能跳过/弱化 YouTube 广告:强化新倒计时检测、按钮重试、速率钉住、失败兜底时间恢复刷新,并添加音量恢复功能
// @author       tientq64 + enhanced by yy
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @match        https://music.youtube.com/*
// @exclude      https://studio.youtube.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict'

    const isMusic = location.hostname === 'music.youtube.com'
    const isShorts = location.pathname.startsWith('/shorts/')
    const isVideoPage = !isMusic

    // 音量状态保存变量
    let originalVolume = 1
    let originalMuted = false
    let hasSavedVolumeState = false

    // 软跳转节流键与状态
    const RELOAD_FLAG_KEY = '__yt_ad_reload_flag__'
    const RELOAD_TS_KEY   = '__yt_ad_reload_ts__'
    const RELOAD_URL_KEY  = '__yt_ad_reload_url__'
    const RELOAD_VOL_KEY  = '__yt_ad_reload_volume__'  // 新增:存储音量状态
    const RELOAD_MUTE_KEY = '__yt_ad_reload_muted__'   // 新增:存储静音状态
    const RELOAD_COOLDOWN_MS = 60_000 // 1 分钟内不重复触发软跳转

    // 进入页面若存在软跳转状态,恢复时间点和音量
    ;(function restoreAfterBypass() {
        try {
            const flag = sessionStorage.getItem(RELOAD_FLAG_KEY)
            const ts   = Number(sessionStorage.getItem(RELOAD_TS_KEY) || 0)
            const url  = sessionStorage.getItem(RELOAD_URL_KEY)
            if (flag === '1' && url && Math.abs(Date.now() - ts) < 20_000) {
                // 恢复后立刻清理,防止回环
                sessionStorage.removeItem(RELOAD_FLAG_KEY)
                sessionStorage.removeItem(RELOAD_TS_KEY)
                sessionStorage.removeItem(RELOAD_URL_KEY)
                
                // 获取保存的音量状态
                const savedVolume = sessionStorage.getItem(RELOAD_VOL_KEY)
                const savedMuted = sessionStorage.getItem(RELOAD_MUTE_KEY)
                if (savedVolume) originalVolume = parseFloat(savedVolume)
                if (savedMuted) originalMuted = savedMuted === 'true'
                sessionStorage.removeItem(RELOAD_VOL_KEY)
                sessionStorage.removeItem(RELOAD_MUTE_KEY)
                
                // 等播放就绪后 seek
                const trySeek = () => {
                    const v = document.querySelector('video.html5-main-video')
                    if (v && !isNaN(v.duration) && v.duration > 1) {
                        v.currentTime = Math.max(0, (new URL(url)).searchParams.get('t') ? v.currentTime : v.currentTime)
                        // 用存储进度恢复
                        const target = Number((window.__yt_ad_restore_time__ || 0))
                        if (target > 0) v.currentTime = target + 1
                        delete window.__yt_ad_restore_time__
                        
                        // 恢复原始音量状态
                        v.volume = originalVolume
                        v.muted = originalMuted
                    } else {
                        requestAnimationFrame(trySeek)
                    }
                }
                requestAnimationFrame(trySeek)
            } else {
                // 清理过期
                sessionStorage.removeItem(RELOAD_FLAG_KEY)
                sessionStorage.removeItem(RELOAD_TS_KEY)
                sessionStorage.removeItem(RELOAD_URL_KEY)
                sessionStorage.removeItem(RELOAD_VOL_KEY)
                sessionStorage.removeItem(RELOAD_MUTE_KEY)
            }
        } catch {}
    })()

    function simulateClick(el) {
        if (!el) return
        ['mouseover', 'mousedown', 'mouseup', 'click'].forEach(type => {
            el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }))
        })
    }

    function findVisibleAdButtons() {
        const selectors = [
            '.ytp-ad-skip-button',
            '.ytp-skip-ad-button',
            '[id^="skip-button"]',
            '.ytp-ad-player-overlay-layout__skip-or-preview-container button',
            '.ytp-ad-player-overlay-layout__skip-or-preview-container .ytp-skip-ad-button',
            '.ytp-ad-skip-button-modern', // 新样式
            'button[aria-label*="Skip"], button[aria-label*="跳过"]',
        ]
        const buttons = []
        selectors.forEach(sel => {
            document.querySelectorAll(sel).forEach(btn => {
                const rect = btn.getBoundingClientRect()
                const visible = btn.offsetParent !== null && rect.width > 0 && rect.height > 0 && getComputedStyle(btn).visibility !== 'hidden'
                if (visible) buttons.push(btn)
            })
        })
        return buttons
    }

    function tryClickAdButton(retries = 30, delay = 400) {
        const buttons = findVisibleAdButtons()
        if (buttons.length > 0) {
            buttons.forEach(btn => simulateClick(btn))
            console.log(`[Ad Skipped] ${buttons.length} button(s) clicked`)
            
            // 广告跳过,恢复音量状态
            restoreVolumeState()
        } else if (retries > 0) {
            setTimeout(() => tryClickAdButton(retries - 1, delay), delay)
        }
    }

    function removeResidualAdUI() {
        const selectors = [
            '.ytp-ad-skip-button',
            '.ytp-skip-ad-button',
            '.ytp-ad-player-overlay-layout__skip-or-preview-container',
            '.ytp-ad-player-overlay',
            '.ytp-ad-image-overlay',
            '.ytp-ad-overlay-container',
            '.ytp-ad-end-screen',
            '.ytp-ad-text-overlay',
            '.ytp-ad-preview-container',
            '.ytp-ad-spinner',
            '.ytp-ad-duration-remaining',
            '.ytp-ad-timed-pie-countdown-container',
            '[class*="ad-countdown"]',
            '.ytp-paid-content-overlay'
        ]
        selectors.forEach(sel => {
            document.querySelectorAll(sel).forEach(el => el.remove())
        })
    }

    function isAdPlaying() {
        // 覆盖新的计时器/沙漏/广告态
        return document.querySelector(
            '.ad-showing, .ytp-ad-player-overlay, .ytp-ad-spinner, .ytp-ad-duration-remaining, .ytp-ad-timed-pie-countdown-container, [class*="ad-countdown"]'
        )
    }

    // 保存当前音量状态
    function saveVolumeState() {
        if (hasSavedVolumeState) return
        
        const v = document.querySelector('video.html5-main-video')
        if (v) {
            originalVolume = v.volume
            originalMuted = v.muted
            hasSavedVolumeState = true
            console.log('[Volume State] Saved:', { volume: originalVolume, muted: originalMuted })
        }
    }

    // 恢复原始音量状态
    function restoreVolumeState() {
        const v = document.querySelector('video.html5-main-video')
        if (v && hasSavedVolumeState) {
            v.volume = originalVolume
            v.muted = originalMuted
            console.log('[Volume State] Restored:', { volume: originalVolume, muted: originalMuted })
            hasSavedVolumeState = false
        }
    }

    // 层1:速率钉住器(持续对抗回滚/钳制)
    let ratePinTimer = null
    function pinAdPlaybackRate(enable) {
        if (!enable) {
            if (ratePinTimer) cancelAnimationFrame(ratePinTimer), ratePinTimer = null
            
            // 停止加速时恢复音量
            restoreVolumeState()
            return
        }
        
        // 开始加速前保存音量状态
        saveVolumeState()
        
        const loop = () => {
            const v = document.querySelector('video.html5-main-video')
            if (v) {
                // 多属性联动,尽量绕过限制
                try {
                    v.muted = true  // 广告期间保持静音
                    v.defaultPlaybackRate = 16
                    if (v.playbackRate !== 16) v.playbackRate = 16
                    // 关闭音高保持,规避卡速
                    if ('preservesPitch' in v) v.preservesPitch = false
                    if ('mozPreservesPitch' in v) v.mozPreservesPitch = false
                    if ('webkitPreservesPitch' in v) v.webkitPreservesPitch = false
                    // 避免暂停状态
                    if (v.paused) v.play().catch(()=>{})
                } catch {}
            }
            ratePinTimer = requestAnimationFrame(loop)
        }
        if (!ratePinTimer) ratePinTimer = requestAnimationFrame(loop)
    }

    // 层2:常规跳过流程
    function attemptSkipPath() {
        if (isShorts) return
        if (!isAdPlaying()) {
            // 如果没有广告,确保音量状态已恢复
            restoreVolumeState()
            return
        }

        const moviePlayerEl = document.querySelector('#movie_player')
        // 优先调用原生 skip
        if (moviePlayerEl?.skipAd) {
            try {
                moviePlayerEl.skipAd()
                console.log('[Ad Skipped] via moviePlayer.skipAd()')
                
                // 广告跳过,恢复音量状态
                restoreVolumeState()
                return
            } catch {}
        }

        // 点击按钮 + 速率钉住
        tryClickAdButton()
        pinAdPlaybackRate(true)

        // 有些场景 seekTo 在广告期被拦截,但我们保持速率钉住直到广告态消失
        removeResidualAdUI()
    }

    // 层3:兜底软跳转(进度恢复刷新)
    // 触发条件:广告状态持续一段时间仍未解除,且不在冷却期
    let adStartAt = 0
    function considerSoftBypass() {
        const adNow = !!isAdPlaying()
        const v = document.querySelector('video.html5-main-video')
        if (adNow && adStartAt === 0) adStartAt = Date.now()
        if (!adNow) {
            // 广告结束,恢复音量状态
            restoreVolumeState()
            pinAdPlaybackRate(false)
            adStartAt = 0
            return
        }

        // 广告持续超过阈值,尝试软跳转
        const AD_STUCK_MS = 5000 // 5s 认为被"困住"
        if (adNow && adStartAt > 0 && Date.now() - adStartAt > AD_STUCK_MS) {
            const lastReload = Number(sessionStorage.getItem(RELOAD_TS_KEY) || 0)
            if (Date.now() - lastReload < RELOAD_COOLDOWN_MS) return

            // 保存当前音量状态
            saveVolumeState()
            
            // 记录进度与 URL,刷新后恢复
            try {
                let currentTime = 0
                if (v && !isNaN(v.currentTime)) currentTime = Math.floor(v.currentTime || 0)
                window.__yt_ad_restore_time__ = currentTime

                sessionStorage.setItem(RELOAD_FLAG_KEY, '1')
                sessionStorage.setItem(RELOAD_TS_KEY, String(Date.now()))
                sessionStorage.setItem(RELOAD_URL_KEY, location.href)
                sessionStorage.setItem(RELOAD_VOL_KEY, String(originalVolume))
                sessionStorage.setItem(RELOAD_MUTE_KEY, String(originalMuted))

                console.log('[Ad Bypass] soft-reload with time restore', currentTime)
                // 使用 history.replaceState + location.reload,避免新开 tab
                history.replaceState(null, '', location.href)
                location.reload()
            } catch (e) {
                console.warn('Soft bypass failed', e)
            } finally {
                adStartAt = 0
                pinAdPlaybackRate(false)
            }
        }
    }

    function hideAdElements() {
        const selectors = [
            '#player-ads',
            '#masthead-ad',
            '.ytp-featured-product',
            '.yt-mealbar-promo-renderer',
            'ytd-merch-shelf-renderer',
            'ytmusic-mealbar-promo-renderer',
            'ytmusic-statement-banner-renderer',
            '#panels > ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"]'
        ]
        const style = document.createElement('style')
        style.textContent = selectors.join(',') + '{ display: none !important; }'
        document.head.appendChild(style)
    }

    function removeInlineAds() {
        const adSelectors = [
            ['ytd-reel-video-renderer', '.ytd-ad-slot-renderer']
        ]
        for (const [container, child] of adSelectors) {
            const el = document.querySelector(container)
            if (el?.querySelector(child)) el.remove()
        }
    }

    hideAdElements()
    if (isVideoPage) {
        setInterval(removeInlineAds, 1000)
        setInterval(removeResidualAdUI, 1000)
        removeInlineAds()
        removeResidualAdUI()
    }

    // 监听 DOM 变化:尝试跳过 + 兜底判定
    const observer = new MutationObserver(() => {
        setTimeout(() => {
            attemptSkipPath()
            considerSoftBypass()
            removeResidualAdUI()
        }, 150)
    })

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['class', 'style', 'hidden', 'aria-hidden']
    })

    // 周期性心跳,防止遗漏
    setInterval(() => {
        attemptSkipPath()
        considerSoftBypass()
    }, 400)
})()