Greasy Fork is available in English.

Dim Watched YouTube Videos

Dim the thumbnails of watched videos on YouTube

// ==UserScript==
// @name         Dim Watched YouTube Videos
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  Dim the thumbnails of watched videos on YouTube
// @author       Shaun Mitchell <shaun@shitchell.com>
// @match        https://www.youtube.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @run-at       document-end
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @grant        GM.getValue
// @grant        GM.setValue
// @license      WTFPL
// ==/UserScript==

/** Dim Watched YouTube Videos *************************************************

# What it does

This script will dim the thumbnails of watched videos on YouTube.

# Why?

This makes it easier to identify videos that you haven't watched yet. Especially
on a channel that you watch frequently, it can be hard to parse through all the
videos to find the ones you haven't seen. This makes those unwatched videos
stand out while the watched videos fade into the background.

# Settings

This script uses GM_config to manage settings. You can access the settings by
clicking the "Open Settings" button in the Userscript manager. The following
settings are available:

## thumbnail_opacity (default: 0.3)

This is the opacity of the thumbnail when the video has been watched. Values
must be a decimal between 0-1. Lower values result in more transparent/faded
thumbnails. If relative opacity is enabled, the opacity of a video will be
determined by this value + the percentage of the video watched. i.e.: fully
watched videos will be set to this opacity, but partially watched videos will be
less transparent. e.g.:

- if relative_opacity == true && thumbnail_opacity == 0.5:
    - 100% watched = 0.5 opacity (the full value of thumbnail_opacity)
    - 75% watched = 0.625 opacity
    - 50% watched = 0.75 opacity
    - 25% watched = 0.875 opacity
    - 0% watched = 1.0 opacity (fully visible)
- if relative_opacity == false && thumbnail_opacity == 0.5:
    - 100% watched = 0.5 opacity
    - 75% watched = 0.5 opacity
    - 50% watched = 0.5 opacity
    - 25% watched = 0.5 opacity
    - 0% watched = 1.0 opacity

## use_relative_opacity (default: true)

If true, enables relative opacity, and thumbnail opacity will be determined
relative to the video watch time. If false, the thumbnail opacity will be set
to a fixed value for all watched videos, regardless of how much of the video
has been watched.

## min_watched (default: 0)

The minimum percentage of the video that must be watched for the thumbnail to
be dimmed. If the video has been watched less than this percentage, the
thumbnail will be set to full opacity. e.g.:

- if min_watched == 50:
    - 100% watched = dimmed
    - 75% watched = dimmed
    - 50% watched = dimmed
    - 25% watched = not dimmed
    - 0% watched = not dimmed
*******************************************************************************/

/** To-do
 * - [ ] Add a "Saved!" message to the settings page when settings are
 *       automatically saved
 */

/*******************************************************************************
 Global variables and default configuration
*******************************************************************************/

const DEFAULT_CONFIG = {
    use_relative_opacity: true,
    thumbnail_opacity: 0.3, // percentage, 0-1
    min_watched: 0, // percentage, 0-100
    opaque_on_hover: true,
    debug: "error",
    dim_throttling_ms: 0 // ms
}
const seenQuerySelectors = [
    "ytd-thumbnail-overlay-resume-playback-renderer > div#progress"
];
const parentThumbnailSelectors = [
    "ytd-thumbnail",
    "div#thumbnail.ytd-rich-grid-media"
]
const seenQuerySelector = seenQuerySelectors.join(", ");
const parentThumbnailSelector = parentThumbnailSelectors.join(", ");
const observeParentSelector = "#content";
const logLevels = {
    none: -1,
    log: 0,
    debug: 0,
    info: 1,
    warn: 2,
    error: 3
}


/*******************************************************************************
 Configuration management
*******************************************************************************/

/** GM_config setup
*******************************************************************************/

const gmc = new GM_config({
    id: 'GM_config-yt_dwv',
    title: 'Dim Watched YouTube Videos',
    fields: {
        SECTION_main: {
            type: 'hidden',
            section: ['Main settings']
        },
        use_relative_opacity: {
            label: 'Use relative opacity',
            type: 'checkbox',
            default: DEFAULT_CONFIG.use_relative_opacity
        },
        thumbnail_opacity: {
            label: 'Thumbnail opacity',
            type: 'float',
            default: DEFAULT_CONFIG.thumbnail_opacity,
            min: 0,
            max: 1
        },
        min_watched: {
            label: 'Minimum percentage watched',
            type: 'int',
            default: DEFAULT_CONFIG.min_watched,
            min: 0,
            max: 100
        },
        opaque_on_hover: {
            label: 'Opaque on hover',
            type: 'checkbox',
            default: DEFAULT_CONFIG.opaque_on_hover
        },
        SECTION_dev: {
            type: 'hidden',
            section: ['Developer options']
        },
        debug: {
            label: 'Log level',
            type: 'select',
            options: ['off', 'error', 'warn', 'info', 'debug'],
            default: DEFAULT_CONFIG.debug
        },
        dim_throttling_ms: {
            label: 'Dimming throttling (ms)',
            type: 'int',
            default: DEFAULT_CONFIG.dim_throttling_ms,
            min: 0
        }
    },
    css: `
        #GM_config-yt_dwv {
            // background-color: #333;
            background-color: rgba(30, 30, 30, 0.95);
            color: #FFF;
            font-family: Arial, sans-serif;
            padding: 1em;
        }
        #GM_config-yt_dwv .section_header {
            font-size: 1.5em;
            GM_config-yt_dwv: 1em;
            padding: 0.5em;
            color: #CF9FFF;
            border: none;
            border-bottom: 1px solid #CF9FFF;
            text-align: left;
            background: none;
        }
        #GM_config-yt_dwv .reset, #GM_config-yt_dwv .saveclose_buttons {
            display: none;
        }
        // #GM_config-yt_dwv .reset {
        //     color: #CF9FFF;
        // }
        // #GM_config-yt_dwv_saveBtn {
        //     display: none;
        // }
        #GM_config-yt_dwv .config_var {
            margin-bottom: 0.5em;
            font-size: 1em;
        }
        #GM_config-yt_dwv .config_var label.field_label,
        #GM_config-yt_dwv .config_var input,
        #GM_config-yt_dwv .config_var select,
        #GM_config-yt_dwv .config_var textarea {
            font-size: inherit;
            width: 15em;
            display: inline-block;
        }
        #GM_config-yt_dwv .config_var input,
        #GM_config-yt_dwv .config_var textarea {
            outline: none;
            background: none;
            color: #FFF;
            border: none;
            border-bottom: 1px solid white;
            transition: border-color 0.2s;
        }
        #GM_config-yt_dwv .config_var input:focus,
        #GM_config-yt_dwv .config_var textarea:focus {
            border-color: #CF9FFF;
            outline: none;
        }
        #GM_config-yt_dwv .field_label {
            font-weight: bold;
            text-align: right;
        }
        #GM_config-yt_dwv .saveclose_buttons {
            background-color: #CF9FFF;
            color: #333;
            padding: 5px 10px;
            cursor: pointer;
            border: none;
            border-radius: 4px;
        }
        #GM_config-yt_dwv input[type="checkbox"] {
            width: 20px;
            height: 20px;
        }
    `,
    /* default frameStyle
        bottom: auto; border: 1px solid #000; display: none; height: 75%;
        left: 0; margin: 0; max-height: 95%; max-width: 95%; opacity: 0;
        overflow: auto; padding: 0; position: fixed; right: auto; top: 0;
        width: 75%; z-index: 9999;
    */
    frameStyle: `
        // Default
        bottom: auto;
        display: none;
        margin: 0;
        padding: 0;
        overflow: auto;
        opacity: 0;
        position: fixed;
        left: 0;
        right: auto;
        top: 0;
        max-height: 95%;
        max-width: 95%;
        z-index: 9999;
        // Custom
        border: none;
        background-color: transparent;
        height: 50%;
        width: 69em;
        border-radius: 1em;
    `,
    events: {
        open: function(frameDocument, frameWindow, frame) {
            const config = this;

            debug(
                `Calling GM_config onOpen event with:`,
                `frameDocument: ${frameDocument}`,
                `frameWindow: ${frameWindow}`,
                `frame: ${frame}`
            );

            // Add an event listener for 'Esc' key within the GM_config iframe
            frameDocument.addEventListener('keydown', event => {
                if (event.key === 'Escape') config.close();
            });

            // Add a click listener on the main document
            document.addEventListener('mousedown', function clickClose(event) {
                const configFrame = document.querySelector('#GM_config-yt_dwv');
                if (configFrame && !configFrame.contains(event.target))
                    config.close();
                document.removeEventListener('mousedown', clickClose);
            });

            // Add an event listener to each field to save the value on change
            for (const fieldId in config.fields) {
                const field = config.fields[fieldId];
                debug(`Adding event listeners to field ${field.id}`);
                field.node.addEventListener('keyup', function() {
                    debug(`Field ${field.id} keyup, saving`);
                    config.save();
                });
                field.node.addEventListener('change', function() {
                    debug(`Field ${field.id} changed, saving`);
                    config.save();
                });
            }
        },
        close: function(...args) {
            info(`Calling GM_config onClose event with args:`, args);
        },
        save: function(config) { },
        reset: function(config) { },
        init: main // Wait for GM_config to load before running the main script
    }
});
unsafeWindow.gmc = gmc;

// Userscript manager menu items
GM_registerMenuCommand('Open Settings', () => gmc.open(), 'o');
GM_registerMenuCommand('Reset Settings', () => {
    gmc.reset();
    gmc.save();
}, 'r');

/** GM_config helper function for async/init
*******************************************************************************/

/**
 * Retrieve a config value
 *
 * This function will retrieve a configuration value from the GM_config object
 * or the default config list if GM_config is not yet initialized. Yay async.
 *
 * @param {String} key  The key of the configuration value to retrieve
 * @returns {any}       The value of the configuration key
 */
function getConfig(key) {
    let source;
    let value;
    if (gmc.isInit) {
        value = gmc.get(key);
        source = "GM_config";
    } else {
        value = DEFAULT_CONFIG[key];
        source = "default"
    }
    debug(`retrieved ${source} config {${key}: ${value}}`);
    return value;
}


/*******************************************************************************
 Utility functions
*******************************************************************************/

/**
 * Log a message to the console if debug mode is enabled
 *
 * This function will log a message to the console if debug mode is enabled in
 * the configuration. If debug mode is disabled, the message will not be logged.
 *
 * @param {String} mode  Console method to use (e.g. "log", "warn", "error")
 * @param {any[]} args   Arguments to log to the console
 * @returns {void}
 */
function log(mode, ...args) {
    if (gmc.isInit) {
        const debugConfig = gmc.get("debug");
        const debugConfigLevel = logLevels[debugConfig];
        const modeLevel = logLevels[mode] || 0;
        const debugEnabled = modeLevel >= debugConfigLevel;
        if (!debugEnabled) return;

        // If the first argument is not a console log method, default to "log" and
        // add "mode" to the arguments list
        if (!["debug", "info", "warn", "error"].includes(mode)) {
            args.unshift(mode);
            mode = "log";
        }

        console[mode](
            `%c[${GM_info.script.name} | ${mode}]`,
            "color: #CF9FFF; font-weight: bold;",
            " ",
            ...args);
    }
}
function debug(...args) { log("debug", ...args); }
function info(...args) { log("info", ...args); }
function warn(...args) { log("warn", ...args); }
function error(...args) { log("error", ...args); }

/**
 * Given a starting node, search for a parent element
 *
 * This function will search for a parent element that matches a given CSS
 * selector starting from a given node. The search will continue up the DOM tree
 * until a matching parent element is found or the maximum distance is reached.
 *
 * @param {HTMLHtmlElement} node  Starting node to search from
 * @param {String} selector       CSS selector to match parent elements against
 * @param {Number} maxDistance    Maximum number of elements before giving up
 * @return {HTMLHtmlElement}      Matching parent element or null if not found
 */
function findParent(node, selector, maxDistance = Infinity) {
    let currentElement = node.parentElement;
    let distance = 0;

    while (currentElement && distance < maxDistance) {
        if (currentElement.matches(selector)) {
            return currentElement; // Return the matching parent element
        }
        // Move up to the next parent
        currentElement = currentElement.parentElement;
        // Increment the distance counter
        distance++;
    }

    // Return null if no match is found within the max distance
    return null;
}

/**
 * Wait for an element to be available in the DOM
 *
 * This function will wait for an element to be available in the DOM before
 * resolving the promise. If the element is already available, the promise will
 * resolve immediately.
 *
 * @link               https://stackoverflow.com/a/61511955
 * @param {String}     A CSS selector to match elements against selector
 * @returns {Promise}  A promise that resolves when the element is found
 */
function waitForElement(selector) {
    return new Promise(resolve => {
        if (document.querySelector(selector)) {
            return resolve(document.querySelector(selector));
        }

        const observer = new MutationObserver(mutations => {
            if (document.querySelector(selector)) {
                observer.disconnect();
                resolve(document.querySelector(selector));
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    });
}


/*******************************************************************************
 Core functions
*******************************************************************************/

/**
 * Dim the thumbnails of watched videos
 *
 * This function will search for watched progress bars on YouTube, attempt to
 * find their associated thumbnail, and dim the thumbnail if the video has been
 * watched. The following configuration options are available:
 * - use_relative_opacity: If true, the thumbnail opacity will be calculated
 *   relative to the percentage watched. If false, the thumbnail opacity will be
 *   set to a fixed value.
 * - thumbnail_opacity: The opacity of the thumbnail when the video has been
 *   watched. This value is used as a lower bound when use_relative_opacity is
 *   true.
 * - min_watched: The minimum percentage of the video that must be watched for
 *   the thumbnail to be dimmed. If the video has been watched less than this
 *   percentage, the thumbnail will be set to full opacity.
 *
 * @returns {void}
 */
function dimWatchedThumbnails(
    minWatched,
    thumbnailOpacity,
    useRelativeOpacity,
    opaqueOnHover
) {
    if (minWatched === undefined)
        minWatched = getConfig("min_watched");
    if (thumbnailOpacity === undefined)
        thumbnailOpacity = getConfig("thumbnail_opacity");
    if (useRelativeOpacity === undefined)
        useRelativeOpacity = getConfig("use_relative_opacity");
    if (opaqueOnHover === undefined)
        opaqueOnHover = getConfig("opaque_on_hover");

    debug(
        `Dimming watched videos with minWatched=${minWatched},`,
        `thumbnailOpacity=${thumbnailOpacity},`,
        `useRelativeOpacity=${useRelativeOpacity}`
    )

    // Collect all of the watched progress bars
    const watchedProgressBars = document.querySelectorAll(seenQuerySelector);

    // Create the combined parent selector
    const parentSelector = parentThumbnailSelector

    if (watchedProgressBars.length > 0)
        info(`Dimming ${watchedProgressBars.length} watched videos`);
    debug('dimming:', watchedProgressBars);
    // Loop over them and try to get their associated thumbnail, dimming it out
    for (const watchedProgressBar of watchedProgressBars) {
        const watchedPercentage = parseInt(watchedProgressBar.style.width);

        debug(
            `Searching for parent of progress bar at ${watchedPercentage}%`,
            watchedProgressBar
        );
        const thumbnail = findParent(watchedProgressBar, parentSelector, 6);
        if (thumbnail === null) {
            error("Could not find parent thumbnail for", watchedProgressBar);
        }
        if (thumbnail) {
            let watchedOpacity;
            if (watchedPercentage < minWatched) {
                debug(
                    `Progress bar ${watchedPercentage} < ${minWatched},`,
                    "setting opacity to 1.0",
                    watchedProgressBar
                );
                watchedOpacity = "";
            } else if (useRelativeOpacity) {
                watchedOpacity = (
                    (
                        thumbnailOpacity * (100 - watchedPercentage) / 100
                    ) + thumbnailOpacity
                );
            } else {
                watchedOpacity = thumbnailOpacity
            }
            debug(
                `${thumbnailOpacity} * (100 - ${watchedPercentage})`,
                `/ 100) + ${thumbnailOpacity} = ${watchedOpacity}`,
                thumbnail
            );
            thumbnail.style.opacity = watchedOpacity;
            if (opaqueOnHover) {
                thumbnail.classList.add("opaque-on-hover");
            }
        }
    }
}

/** Mutation Observer for progress bars
*******************************************************************************/

// Used to throttle the dimming of thumbnails and determine if a dimming is
// scheduled. While a dimming is scheduled, no new dimmings will be scheduled,
// and we can take a break from processing new nodes.
let dimTimeout;

// Throttling function to dim the thumbnails
function scheduleDimThumbnails() {
    if (dimTimeout) {
        debug("Dimming already scheduled, skipping...");
        return
    } else {
        debug("Scheduling dimming...");
        dimTimeout = setTimeout(() => {
            dimTimeout = null;
            dimWatchedThumbnails()
        }, getConfig("dim_throttling_ms"));
    }
}

// Function to process the added nodes
function processAddedNodes(nodes) {
    debug("Processing added nodes:", nodes);
    nodes.forEach(node => {
        // Check if the node matches the seen progress bar selector
        // Note: `nodeType === 1` is an element node
        if (node.nodeType === 1 && node.matches(seenQuerySelector)) {
            info("Found a new progress bar:", node);
            // Queue the dimWatchedThumbnails to run after the dim interval
            scheduleDimThumbnails();
            // Since the scheduled dimming will handle all of the progress bars,
            // we can break out of the loop early
            return;
        }
    });
}

// Mutation observer callback function
function mutationCallback(mutations) {
    if (dimTimeout) return;
    mutations.forEach(mutation => {
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
            processAddedNodes(mutation.addedNodes);
        }
    });
}


/*******************************************************************************
 Main script
*******************************************************************************/

function main() {
    'use strict';
    
    info("Dim Watched YouTube Videos loading...");
    
    // Create a class to toggle the opacity if opaque_on_hover is enabled
    GM_addStyle(`
        .opaque-on-hover { transition: opacity 0.2s; }
        .opaque-on-hover:hover { opacity: 1.0 !important; }
    `);
    
    // Wait for the primary element to become available
    info(`waiting for '${observeParentSelector}'...`);
    waitForElement(observeParentSelector).then(el => {
        info("#primary loaded!");
    
        // Dim the progress bars initially
        dimWatchedThumbnails();
    
        // Set up the MutationObserver
        const observeParent = document.querySelector(observeParentSelector);
        const observer = new MutationObserver(mutationCallback);
    
        info("Watching for new thumbnails in", observeParent);
        observer.observe(
            observeParent,
            {childList: true, subtree: true, attributes: false}
        );
    
        // Expose debug tools if needed
        unsafeWindow._dev = { observer, observeParent, gmc, GM_config };
    });
}