Dim Watched YouTube Videos

Dim the thumbnails of watched videos on YouTube

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
  1. // ==UserScript==
  2. // @name Dim Watched YouTube Videos
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.9
  5. // @description Dim the thumbnails of watched videos on YouTube
  6. // @author Shaun Mitchell <shaun@shitchell.com>
  7. // @match https://www.youtube.com/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  9. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  10. // @run-at document-end
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_addStyle
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @license WTFPL
  16. // ==/UserScript==
  17.  
  18. /** Dim Watched YouTube Videos *************************************************
  19.  
  20. # What it does
  21.  
  22. This script will dim the thumbnails of watched videos on YouTube.
  23.  
  24. # Why?
  25.  
  26. This makes it easier to identify videos that you haven't watched yet. Especially
  27. on a channel that you watch frequently, it can be hard to parse through all the
  28. videos to find the ones you haven't seen. This makes those unwatched videos
  29. stand out while the watched videos fade into the background.
  30.  
  31. # Settings
  32.  
  33. This script uses GM_config to manage settings. You can access the settings by
  34. clicking the "Open Settings" button in the Userscript manager. The following
  35. settings are available:
  36.  
  37. ## thumbnail_opacity (default: 0.3)
  38.  
  39. This is the opacity of the thumbnail when the video has been watched. Values
  40. must be a decimal between 0-1. Lower values result in more transparent/faded
  41. thumbnails. If relative opacity is enabled, the opacity of a video will be
  42. determined by this value + the percentage of the video watched. i.e.: fully
  43. watched videos will be set to this opacity, but partially watched videos will be
  44. less transparent. e.g.:
  45.  
  46. - if relative_opacity == true && thumbnail_opacity == 0.5:
  47. - 100% watched = 0.5 opacity (the full value of thumbnail_opacity)
  48. - 75% watched = 0.625 opacity
  49. - 50% watched = 0.75 opacity
  50. - 25% watched = 0.875 opacity
  51. - 0% watched = 1.0 opacity (fully visible)
  52. - if relative_opacity == false && thumbnail_opacity == 0.5:
  53. - 100% watched = 0.5 opacity
  54. - 75% watched = 0.5 opacity
  55. - 50% watched = 0.5 opacity
  56. - 25% watched = 0.5 opacity
  57. - 0% watched = 1.0 opacity
  58.  
  59. ## use_relative_opacity (default: true)
  60.  
  61. If true, enables relative opacity, and thumbnail opacity will be determined
  62. relative to the video watch time. If false, the thumbnail opacity will be set
  63. to a fixed value for all watched videos, regardless of how much of the video
  64. has been watched.
  65.  
  66. ## min_watched (default: 0)
  67.  
  68. The minimum percentage of the video that must be watched for the thumbnail to
  69. be dimmed. If the video has been watched less than this percentage, the
  70. thumbnail will be set to full opacity. e.g.:
  71.  
  72. - if min_watched == 50:
  73. - 100% watched = dimmed
  74. - 75% watched = dimmed
  75. - 50% watched = dimmed
  76. - 25% watched = not dimmed
  77. - 0% watched = not dimmed
  78. *******************************************************************************/
  79.  
  80. /** To-do
  81. * - [ ] Add a "Saved!" message to the settings page when settings are
  82. * automatically saved
  83. */
  84.  
  85. /*******************************************************************************
  86. Global variables and default configuration
  87. *******************************************************************************/
  88.  
  89. const DEFAULT_CONFIG = {
  90. use_relative_opacity: true,
  91. thumbnail_opacity: 0.3, // percentage, 0-1
  92. min_watched: 0, // percentage, 0-100
  93. opaque_on_hover: true,
  94. debug: "error",
  95. dim_throttling_ms: 0 // ms
  96. }
  97. const seenQuerySelectors = [
  98. "ytd-thumbnail-overlay-resume-playback-renderer > div#progress"
  99. ];
  100. const parentThumbnailSelectors = [
  101. "ytd-thumbnail",
  102. "div#thumbnail.ytd-rich-grid-media"
  103. ]
  104. const seenQuerySelector = seenQuerySelectors.join(", ");
  105. const parentThumbnailSelector = parentThumbnailSelectors.join(", ");
  106. const observeParentSelector = "#content";
  107. const logLevels = {
  108. none: -1,
  109. log: 0,
  110. debug: 0,
  111. info: 1,
  112. warn: 2,
  113. error: 3
  114. }
  115.  
  116.  
  117. /*******************************************************************************
  118. Configuration management
  119. *******************************************************************************/
  120.  
  121. /** GM_config setup
  122. *******************************************************************************/
  123.  
  124. const gmc = new GM_config({
  125. id: 'GM_config-yt_dwv',
  126. title: 'Dim Watched YouTube Videos',
  127. fields: {
  128. SECTION_main: {
  129. type: 'hidden',
  130. section: ['Main settings']
  131. },
  132. use_relative_opacity: {
  133. label: 'Use relative opacity',
  134. type: 'checkbox',
  135. default: DEFAULT_CONFIG.use_relative_opacity
  136. },
  137. thumbnail_opacity: {
  138. label: 'Thumbnail opacity',
  139. type: 'float',
  140. default: DEFAULT_CONFIG.thumbnail_opacity,
  141. min: 0,
  142. max: 1
  143. },
  144. min_watched: {
  145. label: 'Minimum percentage watched',
  146. type: 'int',
  147. default: DEFAULT_CONFIG.min_watched,
  148. min: 0,
  149. max: 100
  150. },
  151. opaque_on_hover: {
  152. label: 'Opaque on hover',
  153. type: 'checkbox',
  154. default: DEFAULT_CONFIG.opaque_on_hover
  155. },
  156. SECTION_dev: {
  157. type: 'hidden',
  158. section: ['Developer options']
  159. },
  160. debug: {
  161. label: 'Log level',
  162. type: 'select',
  163. options: ['off', 'error', 'warn', 'info', 'debug'],
  164. default: DEFAULT_CONFIG.debug
  165. },
  166. dim_throttling_ms: {
  167. label: 'Dimming throttling (ms)',
  168. type: 'int',
  169. default: DEFAULT_CONFIG.dim_throttling_ms,
  170. min: 0
  171. }
  172. },
  173. css: `
  174. #GM_config-yt_dwv {
  175. // background-color: #333;
  176. background-color: rgba(30, 30, 30, 0.95);
  177. color: #FFF;
  178. font-family: Arial, sans-serif;
  179. padding: 1em;
  180. }
  181. #GM_config-yt_dwv .section_header {
  182. font-size: 1.5em;
  183. GM_config-yt_dwv: 1em;
  184. padding: 0.5em;
  185. color: #CF9FFF;
  186. border: none;
  187. border-bottom: 1px solid #CF9FFF;
  188. text-align: left;
  189. background: none;
  190. }
  191. #GM_config-yt_dwv .reset, #GM_config-yt_dwv .saveclose_buttons {
  192. display: none;
  193. }
  194. // #GM_config-yt_dwv .reset {
  195. // color: #CF9FFF;
  196. // }
  197. // #GM_config-yt_dwv_saveBtn {
  198. // display: none;
  199. // }
  200. #GM_config-yt_dwv .config_var {
  201. margin-bottom: 0.5em;
  202. font-size: 1em;
  203. }
  204. #GM_config-yt_dwv .config_var label.field_label,
  205. #GM_config-yt_dwv .config_var input,
  206. #GM_config-yt_dwv .config_var select,
  207. #GM_config-yt_dwv .config_var textarea {
  208. font-size: inherit;
  209. width: 15em;
  210. display: inline-block;
  211. }
  212. #GM_config-yt_dwv .config_var input,
  213. #GM_config-yt_dwv .config_var textarea {
  214. outline: none;
  215. background: none;
  216. color: #FFF;
  217. border: none;
  218. border-bottom: 1px solid white;
  219. transition: border-color 0.2s;
  220. }
  221. #GM_config-yt_dwv .config_var input:focus,
  222. #GM_config-yt_dwv .config_var textarea:focus {
  223. border-color: #CF9FFF;
  224. outline: none;
  225. }
  226. #GM_config-yt_dwv .field_label {
  227. font-weight: bold;
  228. text-align: right;
  229. }
  230. #GM_config-yt_dwv .saveclose_buttons {
  231. background-color: #CF9FFF;
  232. color: #333;
  233. padding: 5px 10px;
  234. cursor: pointer;
  235. border: none;
  236. border-radius: 4px;
  237. }
  238. #GM_config-yt_dwv input[type="checkbox"] {
  239. width: 20px;
  240. height: 20px;
  241. }
  242. `,
  243. /* default frameStyle
  244. bottom: auto; border: 1px solid #000; display: none; height: 75%;
  245. left: 0; margin: 0; max-height: 95%; max-width: 95%; opacity: 0;
  246. overflow: auto; padding: 0; position: fixed; right: auto; top: 0;
  247. width: 75%; z-index: 9999;
  248. */
  249. frameStyle: `
  250. // Default
  251. bottom: auto;
  252. display: none;
  253. margin: 0;
  254. padding: 0;
  255. overflow: auto;
  256. opacity: 0;
  257. position: fixed;
  258. left: 0;
  259. right: auto;
  260. top: 0;
  261. max-height: 95%;
  262. max-width: 95%;
  263. z-index: 9999;
  264. // Custom
  265. border: none;
  266. background-color: transparent;
  267. height: 50%;
  268. width: 69em;
  269. border-radius: 1em;
  270. `,
  271. events: {
  272. open: function(frameDocument, frameWindow, frame) {
  273. const config = this;
  274.  
  275. debug(
  276. `Calling GM_config onOpen event with:`,
  277. `frameDocument: ${frameDocument}`,
  278. `frameWindow: ${frameWindow}`,
  279. `frame: ${frame}`
  280. );
  281.  
  282. // Add an event listener for 'Esc' key within the GM_config iframe
  283. frameDocument.addEventListener('keydown', event => {
  284. if (event.key === 'Escape') config.close();
  285. });
  286.  
  287. // Add a click listener on the main document
  288. document.addEventListener('mousedown', function clickClose(event) {
  289. const configFrame = document.querySelector('#GM_config-yt_dwv');
  290. if (configFrame && !configFrame.contains(event.target))
  291. config.close();
  292. document.removeEventListener('mousedown', clickClose);
  293. });
  294.  
  295. // Add an event listener to each field to save the value on change
  296. for (const fieldId in config.fields) {
  297. const field = config.fields[fieldId];
  298. debug(`Adding event listeners to field ${field.id}`);
  299. field.node.addEventListener('keyup', function() {
  300. debug(`Field ${field.id} keyup, saving`);
  301. config.save();
  302. });
  303. field.node.addEventListener('change', function() {
  304. debug(`Field ${field.id} changed, saving`);
  305. config.save();
  306. });
  307. }
  308. },
  309. close: function(...args) {
  310. info(`Calling GM_config onClose event with args:`, args);
  311. },
  312. save: function(config) { },
  313. reset: function(config) { },
  314. init: main // Wait for GM_config to load before running the main script
  315. }
  316. });
  317. unsafeWindow.gmc = gmc;
  318.  
  319. // Userscript manager menu items
  320. GM_registerMenuCommand('Open Settings', () => gmc.open(), 'o');
  321. GM_registerMenuCommand('Reset Settings', () => {
  322. gmc.reset();
  323. gmc.save();
  324. }, 'r');
  325.  
  326. /** GM_config helper function for async/init
  327. *******************************************************************************/
  328.  
  329. /**
  330. * Retrieve a config value
  331. *
  332. * This function will retrieve a configuration value from the GM_config object
  333. * or the default config list if GM_config is not yet initialized. Yay async.
  334. *
  335. * @param {String} key The key of the configuration value to retrieve
  336. * @returns {any} The value of the configuration key
  337. */
  338. function getConfig(key) {
  339. let source;
  340. let value;
  341. if (gmc.isInit) {
  342. value = gmc.get(key);
  343. source = "GM_config";
  344. } else {
  345. value = DEFAULT_CONFIG[key];
  346. source = "default"
  347. }
  348. debug(`retrieved ${source} config {${key}: ${value}}`);
  349. return value;
  350. }
  351.  
  352.  
  353. /*******************************************************************************
  354. Utility functions
  355. *******************************************************************************/
  356.  
  357. /**
  358. * Log a message to the console if debug mode is enabled
  359. *
  360. * This function will log a message to the console if debug mode is enabled in
  361. * the configuration. If debug mode is disabled, the message will not be logged.
  362. *
  363. * @param {String} mode Console method to use (e.g. "log", "warn", "error")
  364. * @param {any[]} args Arguments to log to the console
  365. * @returns {void}
  366. */
  367. function log(mode, ...args) {
  368. if (gmc.isInit) {
  369. const debugConfig = gmc.get("debug");
  370. const debugConfigLevel = logLevels[debugConfig];
  371. const modeLevel = logLevels[mode] || 0;
  372. const debugEnabled = modeLevel >= debugConfigLevel;
  373. if (!debugEnabled) return;
  374.  
  375. // If the first argument is not a console log method, default to "log" and
  376. // add "mode" to the arguments list
  377. if (!["debug", "info", "warn", "error"].includes(mode)) {
  378. args.unshift(mode);
  379. mode = "log";
  380. }
  381.  
  382. console[mode](
  383. `%c[${GM_info.script.name} | ${mode}]`,
  384. "color: #CF9FFF; font-weight: bold;",
  385. " ",
  386. ...args);
  387. }
  388. }
  389. function debug(...args) { log("debug", ...args); }
  390. function info(...args) { log("info", ...args); }
  391. function warn(...args) { log("warn", ...args); }
  392. function error(...args) { log("error", ...args); }
  393.  
  394. /**
  395. * Given a starting node, search for a parent element
  396. *
  397. * This function will search for a parent element that matches a given CSS
  398. * selector starting from a given node. The search will continue up the DOM tree
  399. * until a matching parent element is found or the maximum distance is reached.
  400. *
  401. * @param {HTMLHtmlElement} node Starting node to search from
  402. * @param {String} selector CSS selector to match parent elements against
  403. * @param {Number} maxDistance Maximum number of elements before giving up
  404. * @return {HTMLHtmlElement} Matching parent element or null if not found
  405. */
  406. function findParent(node, selector, maxDistance = Infinity) {
  407. let currentElement = node.parentElement;
  408. let distance = 0;
  409.  
  410. while (currentElement && distance < maxDistance) {
  411. if (currentElement.matches(selector)) {
  412. return currentElement; // Return the matching parent element
  413. }
  414. // Move up to the next parent
  415. currentElement = currentElement.parentElement;
  416. // Increment the distance counter
  417. distance++;
  418. }
  419.  
  420. // Return null if no match is found within the max distance
  421. return null;
  422. }
  423.  
  424. /**
  425. * Wait for an element to be available in the DOM
  426. *
  427. * This function will wait for an element to be available in the DOM before
  428. * resolving the promise. If the element is already available, the promise will
  429. * resolve immediately.
  430. *
  431. * @link https://stackoverflow.com/a/61511955
  432. * @param {String} A CSS selector to match elements against selector
  433. * @returns {Promise} A promise that resolves when the element is found
  434. */
  435. function waitForElement(selector) {
  436. return new Promise(resolve => {
  437. if (document.querySelector(selector)) {
  438. return resolve(document.querySelector(selector));
  439. }
  440.  
  441. const observer = new MutationObserver(mutations => {
  442. if (document.querySelector(selector)) {
  443. observer.disconnect();
  444. resolve(document.querySelector(selector));
  445. }
  446. });
  447.  
  448. observer.observe(document.body, {
  449. childList: true,
  450. subtree: true
  451. });
  452. });
  453. }
  454.  
  455.  
  456. /*******************************************************************************
  457. Core functions
  458. *******************************************************************************/
  459.  
  460. /**
  461. * Dim the thumbnails of watched videos
  462. *
  463. * This function will search for watched progress bars on YouTube, attempt to
  464. * find their associated thumbnail, and dim the thumbnail if the video has been
  465. * watched. The following configuration options are available:
  466. * - use_relative_opacity: If true, the thumbnail opacity will be calculated
  467. * relative to the percentage watched. If false, the thumbnail opacity will be
  468. * set to a fixed value.
  469. * - thumbnail_opacity: The opacity of the thumbnail when the video has been
  470. * watched. This value is used as a lower bound when use_relative_opacity is
  471. * true.
  472. * - min_watched: The minimum percentage of the video that must be watched for
  473. * the thumbnail to be dimmed. If the video has been watched less than this
  474. * percentage, the thumbnail will be set to full opacity.
  475. *
  476. * @returns {void}
  477. */
  478. function dimWatchedThumbnails(
  479. minWatched,
  480. thumbnailOpacity,
  481. useRelativeOpacity,
  482. opaqueOnHover
  483. ) {
  484. if (minWatched === undefined)
  485. minWatched = getConfig("min_watched");
  486. if (thumbnailOpacity === undefined)
  487. thumbnailOpacity = getConfig("thumbnail_opacity");
  488. if (useRelativeOpacity === undefined)
  489. useRelativeOpacity = getConfig("use_relative_opacity");
  490. if (opaqueOnHover === undefined)
  491. opaqueOnHover = getConfig("opaque_on_hover");
  492.  
  493. debug(
  494. `Dimming watched videos with minWatched=${minWatched},`,
  495. `thumbnailOpacity=${thumbnailOpacity},`,
  496. `useRelativeOpacity=${useRelativeOpacity}`
  497. )
  498.  
  499. // Collect all of the watched progress bars
  500. const watchedProgressBars = document.querySelectorAll(seenQuerySelector);
  501.  
  502. // Create the combined parent selector
  503. const parentSelector = parentThumbnailSelector
  504.  
  505. if (watchedProgressBars.length > 0)
  506. info(`Dimming ${watchedProgressBars.length} watched videos`);
  507. debug('dimming:', watchedProgressBars);
  508. // Loop over them and try to get their associated thumbnail, dimming it out
  509. for (const watchedProgressBar of watchedProgressBars) {
  510. const watchedPercentage = parseInt(watchedProgressBar.style.width);
  511.  
  512. debug(
  513. `Searching for parent of progress bar at ${watchedPercentage}%`,
  514. watchedProgressBar
  515. );
  516. const thumbnail = findParent(watchedProgressBar, parentSelector, 6);
  517. if (thumbnail === null) {
  518. error("Could not find parent thumbnail for", watchedProgressBar);
  519. }
  520. if (thumbnail) {
  521. let watchedOpacity;
  522. if (watchedPercentage < minWatched) {
  523. debug(
  524. `Progress bar ${watchedPercentage} < ${minWatched},`,
  525. "setting opacity to 1.0",
  526. watchedProgressBar
  527. );
  528. watchedOpacity = "";
  529. } else if (useRelativeOpacity) {
  530. watchedOpacity = (
  531. (
  532. thumbnailOpacity * (100 - watchedPercentage) / 100
  533. ) + thumbnailOpacity
  534. );
  535. } else {
  536. watchedOpacity = thumbnailOpacity
  537. }
  538. debug(
  539. `${thumbnailOpacity} * (100 - ${watchedPercentage})`,
  540. `/ 100) + ${thumbnailOpacity} = ${watchedOpacity}`,
  541. thumbnail
  542. );
  543. thumbnail.style.opacity = watchedOpacity;
  544. if (opaqueOnHover) {
  545. thumbnail.classList.add("opaque-on-hover");
  546. }
  547. }
  548. }
  549. }
  550.  
  551. /** Mutation Observer for progress bars
  552. *******************************************************************************/
  553.  
  554. // Used to throttle the dimming of thumbnails and determine if a dimming is
  555. // scheduled. While a dimming is scheduled, no new dimmings will be scheduled,
  556. // and we can take a break from processing new nodes.
  557. let dimTimeout;
  558.  
  559. // Throttling function to dim the thumbnails
  560. function scheduleDimThumbnails() {
  561. if (dimTimeout) {
  562. debug("Dimming already scheduled, skipping...");
  563. return
  564. } else {
  565. debug("Scheduling dimming...");
  566. dimTimeout = setTimeout(() => {
  567. dimTimeout = null;
  568. dimWatchedThumbnails()
  569. }, getConfig("dim_throttling_ms"));
  570. }
  571. }
  572.  
  573. // Function to process the added nodes
  574. function processAddedNodes(nodes) {
  575. debug("Processing added nodes:", nodes);
  576. nodes.forEach(node => {
  577. // Check if the node matches the seen progress bar selector
  578. // Note: `nodeType === 1` is an element node
  579. if (node.nodeType === 1 && node.matches(seenQuerySelector)) {
  580. info("Found a new progress bar:", node);
  581. // Queue the dimWatchedThumbnails to run after the dim interval
  582. scheduleDimThumbnails();
  583. // Since the scheduled dimming will handle all of the progress bars,
  584. // we can break out of the loop early
  585. return;
  586. }
  587. });
  588. }
  589.  
  590. // Mutation observer callback function
  591. function mutationCallback(mutations) {
  592. if (dimTimeout) return;
  593. mutations.forEach(mutation => {
  594. if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
  595. processAddedNodes(mutation.addedNodes);
  596. }
  597. });
  598. }
  599.  
  600.  
  601. /*******************************************************************************
  602. Main script
  603. *******************************************************************************/
  604.  
  605. function main() {
  606. 'use strict';
  607. info("Dim Watched YouTube Videos loading...");
  608. // Create a class to toggle the opacity if opaque_on_hover is enabled
  609. GM_addStyle(`
  610. .opaque-on-hover { transition: opacity 0.2s; }
  611. .opaque-on-hover:hover { opacity: 1.0 !important; }
  612. `);
  613. // Wait for the primary element to become available
  614. info(`waiting for '${observeParentSelector}'...`);
  615. waitForElement(observeParentSelector).then(el => {
  616. info("#primary loaded!");
  617. // Dim the progress bars initially
  618. dimWatchedThumbnails();
  619. // Set up the MutationObserver
  620. const observeParent = document.querySelector(observeParentSelector);
  621. const observer = new MutationObserver(mutationCallback);
  622. info("Watching for new thumbnails in", observeParent);
  623. observer.observe(
  624. observeParent,
  625. {childList: true, subtree: true, attributes: false}
  626. );
  627. // Expose debug tools if needed
  628. unsafeWindow._dev = { observer, observeParent, gmc, GM_config };
  629. });
  630. }