- // ==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 };
- });
- }