YouTube Watched Video Dimmer

Dims watched YouTube videos proportionally to the watched time.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube Watched Video Dimmer
// @namespace    https://greasyfork.org/users/1458847
// @version      1.1
// @license      MIT
// @description  Dims watched YouTube videos proportionally to the watched time.
// @author       Ev Haus, netjeff, actionless
// @match        https://*.youtube.com/*
// @match        https://youtube.com/*
// @noframes
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

/*
 * This script is based on the original "YouTube: Hide Watched Videos" by Ev Haus, netjeff, and actionless.
 * Modifications include:
 * - Simplified the code by removing Shorts and hide options.
 * - Implemented proportional dimming based on watched time.
 * - Settings for dimming are now configurable directly within the code.
 */

(function () {
    "use strict";

    const WATCHED_THRESHOLD_PERCENT = 10;
    const MIN_DIM_OPACITY = 0.2;
    const MAX_DIM_OPACITY = 0.9;
    const DEBUG = false;

    if (
        typeof trustedTypes !== 'undefined' &&
        trustedTypes.defaultPolicy === null
    ) {
        const s = (s) => s;
        trustedTypes.createPolicy('default', {
            createHTML: s,
            createScriptURL: s,
            createScript: s,
        });
    }

    const logDebug = (...msgs) => {
        if (DEBUG) console.debug('[YT-HWV]', msgs);
    };

    const addStyle = (aCss) => {
        const head = document.getElementsByTagName('head')[0];
        if (head) {
            const style = document.createElement('style');
            style.setAttribute('type', 'text/css');
            style.textContent = aCss;
            head.appendChild(style);
            return style;
        }
        return null;
    };

    const css = `
    .YT-HWV-WATCHED-HIDDEN {
        display: none !important
    }

    .YT-HWV-HIDDEN-ROW-PARENT {
        padding-bottom: 10px
    }

    .YT-HWV-BUTTONS {
        background: transparent;
        border: 1px solid var(--ytd-searchbox-legacy-border-color);
        border-radius: 40px;
        display: flex;
        gap: 5px;
        margin: 0 20px;
    }

    .YT-HWV-BUTTON {
        align-items: center;
        background: transparent;
        border: 0;
        border-radius: 40px;
        color: var(--yt-spec-icon-inactive);
        cursor: pointer;
        display: flex;
        height: 40px;
        justify-content: center;
        outline: 0;
        width: 40px;
    }

    .YT-HWV-BUTTON:focus,
    .YT-HWV-BUTTON:hover {
        background: var(--yt-spec-badge-chip-background);
    }

    .YT-HWV-BUTTON-DISABLED {
        color: var(--yt-spec-icon-disabled)
    }
    `;
    addStyle(css);

    const BUTTONS = [
        {
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 48 48"><path fill="currentColor" d="M24 9C14 9 5.46 15.22 2 24c3.46 8.78 12 15 22 15 10.01 0 18.54-6.22 22-15-3.46-8.78-11.99-15-22-15zm0 25c-5.52 0-10-4.48-10-10s4.48-10 10-10 10 4.48 10 10-4.48 10-10 10zm0-16c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6z"/></svg>',
            name: 'Toggle Watched Videos',
            stateKey: 'YTHWV_STATE',
            type: 'toggle',
        },
    ];

    const debounce = function (func, wait, immediate) {
        let timeout;
        return (...args) => {
            const later = () => {
                timeout = null;
                if (!immediate) func.apply(this, args);
            };
            const callNow = immediate && !timeout;
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
            if (callNow) func.apply(this, args);
        };
    };

    const findWatchedElements = () => {
        const watched = document.querySelectorAll([
                '.ytd-thumbnail-overlay-resume-playback-renderer',
                '.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegmentModern',
            ].join(','),
        );

        const withThreshold = Array.from(watched).filter((bar) => {
            return (
                bar && bar.style.width &&
                Number.parseInt(bar.style.width, 10) >= WATCHED_THRESHOLD_PERCENT
            );
        });

        logDebug(
            `Found ${watched.length} watched elements, ${withThreshold.length} meeting threshold (${WATCHED_THRESHOLD_PERCENT}%)`
        );

        return withThreshold;
    };

    const findButtonAreaTarget = () => {
        return document.querySelector('#container #end #buttons');
    };

    const determineYoutubeSection = () => {
        const { href } = window.location;

        let youtubeSection = 'misc';
        if (href.includes('/watch?')) {
            youtubeSection = 'watch';
        } else if (
            href.match(/.*\/(user|channel|c)\/.+\/videos/u) ||
            href.match(/.*\/@.*/u)
        ) {
            youtubeSection = 'channel';
        } else if (href.includes('/feed/subscriptions')) {
            youtubeSection = 'subscriptions';
        } else if (href.includes('/feed/history')) {
             youtubeSection = 'history';
        } else if (href.includes('/feed/trending')) {
            youtubeSection = 'trending';
        } else if (href.includes('/playlist?')) {
            youtubeSection = 'playlist';
        } else if (href.includes('/results?')) {
            youtubeSection = 'search';
        } else if (href === 'https://www.youtube.com/' || href === 'http://www.youtube.com/') {
            youtubeSection = 'home';
        }

        return youtubeSection;
    };

    const calculateOpacity = (percentage) => {
        const sigmoidFactor = 4;

        const clampedPercent = Math.max(WATCHED_THRESHOLD_PERCENT, Math.min(100, percentage));
        const normalizedPercentage = (clampedPercent - WATCHED_THRESHOLD_PERCENT) / (100 - WATCHED_THRESHOLD_PERCENT);
        const shiftedPercentage = (normalizedPercentage - 0.5) * sigmoidFactor;
        const sigmoid = 1 / (1 + Math.exp(-shiftedPercentage));
        const invertedSigmoid = 1 - sigmoid;
        const opacity = MIN_DIM_OPACITY + invertedSigmoid * (MAX_DIM_OPACITY - MIN_DIM_OPACITY);

        return Math.max(MIN_DIM_OPACITY, Math.min(MAX_DIM_OPACITY, opacity));
    };

    const updateClassOnWatchedItems = async () => {
        document
            .querySelectorAll('.YT-HWV-WATCHED-HIDDEN')
            .forEach((el) => el.classList.remove('YT-HWV-WATCHED-HIDDEN'));

        const potentialItems = document.querySelectorAll(
            'ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-item-section-renderer, ytd-playlist-video-renderer, ytd-compact-video-renderer, ytd-playlist-panel-video-renderer, ytd-video-renderer'
        );
        potentialItems.forEach(el => {
             if (el.style.opacity) {
                 el.style.opacity = '';
             }
             el.classList.remove('YT-HWV-HIDDEN-ROW-PARENT');
        });

        if (window.location.href.includes('/feed/history')) return;

        const section = determineYoutubeSection();
        const state = await GM_getValue(`YTHWV_STATE_${section}`, 'normal');

        if (state === 'normal') return;

        findWatchedElements().forEach((progressBarElement) => {
            const percentage = Number.parseInt(progressBarElement.style.width, 10);
            if (isNaN(percentage)) return;

            let itemToModify;
            let itemToDimOnly;

            if (section === 'subscriptions') {
                 itemToModify =
                    progressBarElement.closest('.ytd-rich-item-renderer') ||
                    progressBarElement.closest('.ytd-grid-video-renderer') ||
                    progressBarElement.closest('ytd-item-section-renderer');

            } else if (section === 'playlist') {
                itemToModify = progressBarElement.closest('ytd-playlist-video-renderer');
            } else if (section === 'watch') {
                itemToModify = progressBarElement.closest('ytd-compact-video-renderer');

                if (itemToModify?.closest('ytd-compact-autoplay-renderer')) {
                    itemToModify = null;
                }

                const watchedItemInPlaylistPanel = progressBarElement.closest('ytd-playlist-panel-video-renderer');
                if (!itemToModify && watchedItemInPlaylistPanel) {
                    itemToDimOnly = watchedItemInPlaylistPanel;
                }
            } else {
                 itemToModify =
                    progressBarElement.closest('ytd-rich-item-renderer') ||
                    progressBarElement.closest('ytd-video-renderer') ||
                    progressBarElement.closest('ytd-grid-video-renderer');
            }

            if (itemToModify) {
                itemToModify.style.opacity = '';
                itemToModify.classList.remove('YT-HWV-WATCHED-HIDDEN');
               itemToModify.classList.remove('YT-HWV-HIDDEN-ROW-PARENT');

                if (state === 'dimmed') {
                    const opacity = calculateOpacity(percentage);
                    itemToModify.style.opacity = opacity.toFixed(2);
                    logDebug(`Dimming item: ${itemToModify.tagName} to opacity ${opacity.toFixed(2)} (${percentage}%)`);
                }
            }

            if (itemToDimOnly && state === 'dimmed') {
                 itemToDimOnly.style.opacity = '';
                itemToModify?.classList.remove('YT-HWV-WATCHED-HIDDEN');

                const opacity = calculateOpacity(percentage);
                itemToDimOnly.style.opacity = opacity.toFixed(2);
                logDebug(`Dimming only item: ${itemToDimOnly.tagName} to opacity ${opacity.toFixed(2)} (${percentage}%)`);
            }
        });
    };

    const renderButtons = async () => {
        const target = findButtonAreaTarget();
        if (!target) return;

        const existingButtons = target.parentNode.querySelector('.YT-HWV-BUTTONS');

        const buttonArea = document.createElement('div');
        buttonArea.classList.add('YT-HWV-BUTTONS');

        for (const { icon, name, stateKey, type } of BUTTONS) {
            if (type === 'toggle') {
                const section = determineYoutubeSection();
                 if (section === 'history') {
                     if (existingButtons) existingButtons.remove();
                     return;
                 }

                const storageKey = `${stateKey}_${section}`;
                const toggleButtonState = await GM_getValue(storageKey, 'normal');

                const button = document.createElement('button');
                button.title = `${name} : currently "${toggleButtonState}" for section "${section}"`;
                button.classList.add('YT-HWV-BUTTON');
                if (toggleButtonState === 'dimmed') {
                    button.classList.add('YT-HWV-BUTTON-DISABLED');
                }
                let currentIcon = icon;
                button.innerHTML = currentIcon;
                buttonArea.appendChild(button);

                button.addEventListener('click', async () => {
                    const currentState = await GM_getValue(storageKey, 'normal');
                    logDebug(`Button ${name} clicked. Current state: ${currentState}, Section: ${section}`);

                    let newState = 'dimmed';
                     if (currentState === 'dimmed') {
                         newState = 'normal';
                     }

                    logDebug(`Setting new state to: ${newState}`);
                    await GM_setValue(storageKey, newState);

                    await updateClassOnWatchedItems();
                    await renderButtons();
                });
            }
        }

        if (buttonArea.hasChildNodes()) {
            if (existingButtons) {
                 if (existingButtons.innerHTML !== buttonArea.innerHTML) {
                     target.parentNode.replaceChild(buttonArea, existingButtons);
                     logDebug('Re-rendered menu buttons');
                 }
            } else {
                target.parentNode.insertBefore(buttonArea, target);
                logDebug('Rendered menu buttons');
            }
        } else if (existingButtons) {
             existingButtons.remove();
             logDebug('Removed menu buttons');
        }
    };

    const run = debounce(async (mutations) => {
        if (
            mutations &&
            mutations.length > 0 &&
             mutations.every(m =>
                m.target.classList?.contains('YT-HWV-BUTTON') ||
                m.target.classList?.contains('YT-HWV-BUTTONS') ||
                 m.addedNodes?.[0]?.classList?.contains('YT-HWV-BUTTONS') ||
                 m.removedNodes?.[0]?.classList?.contains('YT-HWV-BUTTONS')
            )
        ) {
            return;
        }

        logDebug('Running check for watched videos due to DOM change or initial load');
        await updateClassOnWatchedItems();
        await renderButtons();
    }, 250);

    const send = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function (data) {
        this.addEventListener(
            'readystatechange',
            function () {
                 if (this.readyState === 4 && (
                     this.responseURL.includes('/browse_ajax') ||
                     this.responseURL.includes('/player') ||
                     this.responseURL.includes('/search') ||
                     this.responseURL.includes('/next')
                    ))
                 {
                    logDebug(`AJAX detected (${this.responseURL}), scheduling update.`);
                    setTimeout(run, 500);
                }
            },
            false,
        );
        send.call(this, data);
    };

    const observeDOM = (() => {
        const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;

        return (obj, callback) => {
            if (!obj || !MutationObserver) {
                 console.warn('[YT-HWV] MutationObserver not available.');
                 return;
             }

            logDebug('Attaching DOM listener');
            const obs = new MutationObserver((mutations) => {
                 if (mutations.some(m => m.addedNodes.length > 0 || m.removedNodes.length > 0)) {
                    callback(mutations);
                }
            });

            obs.observe(obj, { childList: true, subtree: true });
        };
    })();

    logDebug('Starting Script (Simplified GM with Variable Dimming)');
    observeDOM(document.body, run);
    run();

})();