Greasy Fork 还支持 简体中文。

Youtube Peek Preview

See video thumbnails, ratings and other details when you mouse over a Youtube link from almost any website

  1. // ==UserScript==
  2. // @name Youtube Peek Preview
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.2.3
  5. // @description See video thumbnails, ratings and other details when you mouse over a Youtube link from almost any website
  6. // @author scriptpost
  7. // @match *://*/*
  8. // @exclude https://twitter.com/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_deleteValue
  12. // @grant GM_registerMenuCommand
  13. // ==/UserScript==
  14. (function () {
  15. // Remove deprecated storage structure from earlier versions.
  16. // Configure settings through your browser extension icon, under "Youtube Peek Settings"
  17. const settings = JSON.parse(GM_getValue('userSettings', '{}'));
  18. if (settings.hasOwnProperty('REGIONS')) {
  19. GM_deleteValue('userSettings');
  20. }
  21. })();
  22. /*!
  23. * Clamp.js 0.5.1
  24. *
  25. * Copyright 2011-2013, Joseph Schmitt http://joe.sh
  26. * Released under the WTFPL license
  27. * http://sam.zoy.org/wtfpl/
  28. */
  29. (function () {
  30. /**
  31. * Clamps a text node.
  32. * @param {HTMLElement} element. Element containing the text node to clamp.
  33. * @param {Object} options. Options to pass to the clamper.
  34. */
  35. function clamp(element, options) {
  36. options = options || {};
  37. var self = this, win = window, opt = {
  38. clamp: options.clamp || 2,
  39. useNativeClamp: typeof (options.useNativeClamp) != 'undefined' ? options.useNativeClamp : true,
  40. splitOnChars: options.splitOnChars || ['.', '-', '–', '—', ' '],
  41. animate: options.animate || false,
  42. truncationChar: options.truncationChar || '…',
  43. truncationHTML: options.truncationHTML
  44. }, sty = element.style, originalText = element.innerHTML, supportsNativeClamp = typeof (element.style.webkitLineClamp) != 'undefined', clampValue = opt.clamp, isCSSValue = clampValue.indexOf && (clampValue.indexOf('px') > -1 || clampValue.indexOf('em') > -1), truncationHTMLContainer;
  45. if (opt.truncationHTML) {
  46. truncationHTMLContainer = document.createElement('span');
  47. truncationHTMLContainer.innerHTML = opt.truncationHTML;
  48. }
  49. // UTILITY FUNCTIONS
  50. /**
  51. * Return the current style for an element.
  52. * @param {HTMLElement} elem The element to compute.
  53. * @param {string} prop The style property.
  54. * @returns {number}
  55. */
  56. function computeStyle(elem, prop) {
  57. if (!win.getComputedStyle) {
  58. win.getComputedStyle = function (el, pseudo) {
  59. this.el = el;
  60. this.getPropertyValue = function (prop) {
  61. var re = /(\-([a-z]){1})/g;
  62. if (prop == 'float')
  63. prop = 'styleFloat';
  64. if (re.test(prop)) {
  65. prop = prop.replace(re, function () {
  66. return arguments[2].toUpperCase();
  67. });
  68. }
  69. return el.currentStyle && el.currentStyle[prop] ? el.currentStyle[prop] : null;
  70. };
  71. return this;
  72. };
  73. }
  74. return win.getComputedStyle(elem, null).getPropertyValue(prop);
  75. }
  76. /**
  77. * Returns the maximum number of lines of text that should be rendered based
  78. * on the current height of the element and the line-height of the text.
  79. */
  80. function getMaxLines(height) {
  81. var availHeight = height || element.clientHeight, lineHeight = getLineHeight(element);
  82. return Math.max(Math.floor(availHeight / lineHeight), 0);
  83. }
  84. /**
  85. * Returns the maximum height a given element should have based on the line-
  86. * height of the text and the given clamp value.
  87. */
  88. function getMaxHeight(clmp) {
  89. var lineHeight = getLineHeight(element);
  90. return lineHeight * clmp;
  91. }
  92. /**
  93. * Returns the line-height of an element as an integer.
  94. */
  95. function getLineHeight(elem) {
  96. var lh = computeStyle(elem, 'line-height');
  97. if (lh == 'normal') {
  98. // Normal line heights vary from browser to browser. The spec recommends
  99. // a value between 1.0 and 1.2 of the font size. Using 1.1 to split the diff.
  100. lh = parseInt(computeStyle(elem, 'font-size')) * 1.2;
  101. }
  102. return parseInt(lh);
  103. }
  104. // MEAT AND POTATOES (MMMM, POTATOES...)
  105. var splitOnChars = opt.splitOnChars.slice(0), splitChar = splitOnChars[0], chunks, lastChunk;
  106. /**
  107. * Gets an element's last child. That may be another node or a node's contents.
  108. */
  109. function getLastChild(elem) {
  110. //Current element has children, need to go deeper and get last child as a text node
  111. if (elem.lastChild.children && elem.lastChild.children.length > 0) {
  112. return getLastChild(Array.prototype.slice.call(elem.children).pop());
  113. }
  114. //This is the absolute last child, a text node, but something's wrong with it. Remove it and keep trying
  115. else if (!elem.lastChild || !elem.lastChild.nodeValue || elem.lastChild.nodeValue == '' || elem.lastChild.nodeValue == opt.truncationChar) {
  116. elem.lastChild.parentNode.removeChild(elem.lastChild);
  117. return getLastChild(element);
  118. }
  119. //This is the last child we want, return it
  120. else {
  121. return elem.lastChild;
  122. }
  123. }
  124. /**
  125. * Removes one character at a time from the text until its width or
  126. * height is beneath the passed-in max param.
  127. */
  128. function truncate(target, maxHeight) {
  129. if (!maxHeight) {
  130. return;
  131. }
  132. /**
  133. * Resets global variables.
  134. */
  135. function reset() {
  136. splitOnChars = opt.splitOnChars.slice(0);
  137. splitChar = splitOnChars[0];
  138. chunks = null;
  139. lastChunk = null;
  140. }
  141. var nodeValue = target.nodeValue.replace(opt.truncationChar, '');
  142. //Grab the next chunks
  143. if (!chunks) {
  144. //If there are more characters to try, grab the next one
  145. if (splitOnChars.length > 0) {
  146. splitChar = splitOnChars.shift();
  147. }
  148. //No characters to chunk by. Go character-by-character
  149. else {
  150. splitChar = '';
  151. }
  152. chunks = nodeValue.split(splitChar);
  153. }
  154. //If there are chunks left to remove, remove the last one and see if
  155. // the nodeValue fits.
  156. if (chunks.length > 1) {
  157. // console.log('chunks', chunks);
  158. lastChunk = chunks.pop();
  159. // console.log('lastChunk', lastChunk);
  160. applyEllipsis(target, chunks.join(splitChar));
  161. }
  162. //No more chunks can be removed using this character
  163. else {
  164. chunks = null;
  165. }
  166. //Insert the custom HTML before the truncation character
  167. if (truncationHTMLContainer) {
  168. target.nodeValue = target.nodeValue.replace(opt.truncationChar, '');
  169. element.innerHTML = target.nodeValue + ' ' + truncationHTMLContainer.innerHTML + opt.truncationChar;
  170. }
  171. //Search produced valid chunks
  172. if (chunks) {
  173. //It fits
  174. if (element.clientHeight <= maxHeight) {
  175. //There's still more characters to try splitting on, not quite done yet
  176. if (splitOnChars.length >= 0 && splitChar != '') {
  177. applyEllipsis(target, chunks.join(splitChar) + splitChar + lastChunk);
  178. chunks = null;
  179. }
  180. //Finished!
  181. else {
  182. return element.innerHTML;
  183. }
  184. }
  185. }
  186. //No valid chunks produced
  187. else {
  188. //No valid chunks even when splitting by letter, time to move
  189. //on to the next node
  190. if (splitChar == '') {
  191. applyEllipsis(target, '');
  192. target = getLastChild(element);
  193. reset();
  194. }
  195. }
  196. //If you get here it means still too big, let's keep truncating
  197. if (opt.animate) {
  198. setTimeout(function () {
  199. truncate(target, maxHeight);
  200. }, opt.animate === true ? 10 : opt.animate);
  201. }
  202. else {
  203. return truncate(target, maxHeight);
  204. }
  205. }
  206. function applyEllipsis(elem, str) {
  207. elem.nodeValue = str + opt.truncationChar;
  208. }
  209. // CONSTRUCTOR
  210. if (clampValue == 'auto') {
  211. clampValue = getMaxLines();
  212. }
  213. else if (isCSSValue) {
  214. clampValue = getMaxLines(parseInt(clampValue));
  215. }
  216. var clampedText;
  217. if (supportsNativeClamp && opt.useNativeClamp) {
  218. sty.overflow = 'hidden';
  219. sty.textOverflow = 'ellipsis';
  220. sty.webkitBoxOrient = 'vertical';
  221. sty.display = '-webkit-box';
  222. sty.webkitLineClamp = clampValue;
  223. if (isCSSValue) {
  224. sty.height = opt.clamp + 'px';
  225. }
  226. }
  227. else {
  228. var height = getMaxHeight(clampValue);
  229. if (height <= element.clientHeight) {
  230. clampedText = truncate(getLastChild(element), height);
  231. }
  232. }
  233. return {
  234. 'original': originalText,
  235. 'clamped': clampedText
  236. };
  237. }
  238. window.$clamp = clamp;
  239. })();
  240. (function () {
  241. // Begin script: Youtube Peek
  242. 'use strict';
  243. const DEFAULT_OPTIONS = {
  244. regions: [],
  245. noTooltip: true,
  246. allowOnYoutube: false
  247. };
  248. const OPTIONS = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS)));
  249. const apiKey = 'AIzaSyBnibVlVDGC7t_wd3ZErVK6XF3hp3G7xtA';
  250. const re = {
  251. isVideoLink: /(?:youtube\.com\/(?:watch\?.*v=|attribution_link)|youtu\.be\/|y2u\.be\/)/i,
  252. getVideoId: /(?:youtube\.com\/watch\?.*v=|youtu\.be\/|y2u\.be\/)([-_A-Za-z0-9]{11})/i,
  253. getTimeLength: /\d+[A-Z]/g,
  254. };
  255. const cache = {};
  256. const delay_open = 100;
  257. const delay_close = 0;
  258. let tmo_open;
  259. let tmo_close;
  260. const _stylesheet = String.raw `<style type="text/css" id="yt-peek">.yt-peek,.yt-peek-loading{position:absolute;z-index:123456789}.yt-peek,.yt-peek-cfg{box-shadow:var(--shadow-big);--shadow-big:0 4px 8px hsla(0,0%,0%,.2),0 8px 16px hsla(0,0%,0%,.2),0 4px 4px hsla(0,0%,100%,.1)}.yt-peek-loading{width:16px;height:16px;border-radius:50%;background:#fff;border-width:6px 0;border-style:solid;border-color:#8aa4b1;box-sizing:border-box;animation-duration:1s;animation-name:spin;animation-iteration-count:infinite;animation-timing-function:cubic-bezier(.67,.88,.53,.37)}.yt-peek .yt-peek-loading{top:0;bottom:0;left:0;right:0;margin:auto;background:0 0;border-color:hsla(200,20%,62%,.5);width:32px;height:32px}.yt-peek .yt-peek-chan,.yt-peek-blocked{border-top:1px solid hsla(0,0%,100%,.1);box-sizing:border-box}@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}.yt-peek{box-sizing:border-box;background:#232628;margin:0;padding:0;color:#999!important;font:400 12px/1.2 "segoe ui",arial,sans-serif!important;border-radius:3px!important;overflow:hidden}.yt-peek-cols{display:flex;flex-direction:row;position:relative}.yt-peek-cols>div{display:flex;flex:1 1 auto}.yt-peek-info{box-sizing:border-box;max-width:230px;display:flex;flex:1 0 auto;flex-direction:column}.yt-peek-row{display:flex;justify-content:space-between}.yt-peek-info>div{padding:6px 12px}.yt-peek .yt-peek-title{font-size:14px;color:#fff}.yt-peek .yt-peek-desc{padding-top:0;font-size:14px}.yt-peek .yt-peek-date{display:inline-block;order:-1}.yt-peek .yt-peek-views{display:inline-block}.yt-peek .yt-peek-chan{color:#fff;position:absolute;bottom:0;width:100%}.yt-peek-preview{position:relative;flex-direction:column;order:-1;justify-content:space-between}.yt-peek-thumb{position:relative;min-height:169px;width:300px}.yt-peek-thumb img{object-fit:none;display:block;width:100%}.yt-peek-length{font:700 12px/1 arial,sans-serif;position:absolute;bottom:8px;left:4px;padding:2px 5px;color:#fff;background:hsla(0,0%,0%,.9);border-radius:3px}.yt-peek-score{margin:1px 0;width:100%;height:3px;background:#ccc}.yt-peek-score div{height:inherit;background:#0098e5}.yt-peek-blocked{padding:5px 12px;color:#b2b2b2;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:530px}.yt-peek-blocked em{font-weight:700;font-style:normal;color:#fff;padding:0 2px;background:#dc143c;border-radius:2px}.yt-peek-cfg{font:400 12px/1.35 sans-serif;position:fixed;top:0;right:15px;left:0;margin:auto;padding:0 15px;width:300px;box-sizing:border-box;color:#000;background:#fff;border-radius:0 0 3px 3px;border-width:0 1px 1px;border-style:solid;border-color:#999;max-height:100vh;overflow:auto;z-index:12345679}.yt-peek-cfg-footer,.yt-peek-cfg-item{padding:10px 0}.yt-peek-cfg-heading{padding:10px 0;font:400 14px/1 sans-serif}.yt-peek-cfg-label{font-weight:700}.yt-peek-cfg-item label{display:block}.yt-peek-cfg-desc{color:#8c8c8c;margin:.25em 0 0}.yt-peek-cfg-item textarea{box-sizing:border-box;min-width:100px;width:100%;min-height:2em}.yt-peek-cfg button{display:inline-block;font:400 12px/1 sans-serif;border:none;border-radius:3px;margin:0 .5em 0 0;padding:10px 18px;transition:background .2s;cursor:default}.yt-peek-cfg-save{color:#fff;background:#d82626}.yt-peek-cfg-cancel{color:#000;background:0 0}.yt-peek-cfg-save:hover{background:#b71414}.yt-peek-cfg-cancel:hover{background:#e5e5e5}.yt-peek-missing .yt-peek-chan,.yt-peek-missing .yt-peek-row,.yt-peek-missing .yt-peek-thumb{display:none}.yt-peek,.yt-peek-loading,.yt-peek-thumb img{opacity:0;transition:opacity .25s}.yt-peek-ready{opacity:1!important}</style>`;
  261. document.body.insertAdjacentHTML('beforeend', _stylesheet);
  262. function containsEncodedComponents(x) {
  263. return (decodeURI(x) !== decodeURIComponent(x));
  264. }
  265. /**
  266. * Check if we're on a particular domain name.
  267. * @param host Name of the website.
  268. */
  269. function site(host) {
  270. return window.location.host.includes(host);
  271. }
  272. function handleMouseOver(ev) {
  273. let target = ev.target;
  274. target = target.closest('a');
  275. if (!target)
  276. return;
  277. let href = target.href;
  278. if (!href)
  279. return;
  280. // Some sites put the URL in a dataset. (note: twitter blocks goog API)
  281. if (site('twitter.com')) {
  282. const dataUrl = target.dataset.expandedUrl;
  283. if (dataUrl)
  284. href = dataUrl;
  285. }
  286. // Check if the URL goes to a youtube video.
  287. if (!re.isVideoLink.test(href))
  288. return;
  289. // Need to know if it's an attribution link so we can read the encoded params.
  290. if (/attribution_link\?/i.test(href)) {
  291. const URIComponent = href.substr(href.indexOf('%2Fwatch%3Fv%3D'));
  292. if (containsEncodedComponents(URIComponent)) {
  293. href = 'https://www.youtube.com' + decodeURIComponent(URIComponent);
  294. }
  295. }
  296. // Finally get the video ID;
  297. const id = re.getVideoId.exec(href)[1];
  298. if (!id)
  299. return console.error('Invalid video ID');
  300. window.clearTimeout(tmo_open);
  301. window.clearTimeout(tmo_close);
  302. const noTooltip = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS))).noTooltip;
  303. if (noTooltip) {
  304. target.removeAttribute('title');
  305. }
  306. tmo_open = window.setTimeout(() => {
  307. if (!cache.hasOwnProperty(id)) {
  308. const parts = 'snippet,contentDetails,statistics';
  309. requestVideoData(ev, id, parts);
  310. }
  311. else {
  312. handleSuccess(ev, id, cache[id]);
  313. }
  314. }, delay_open);
  315. function handleMouseLeave(ev) {
  316. target.removeEventListener('mouseleave', handleMouseLeave);
  317. window.clearTimeout(tmo_open);
  318. tmo_open = null;
  319. tmo_close = window.setTimeout(() => {
  320. removePeekBoxes();
  321. }, delay_close);
  322. }
  323. target.addEventListener('mouseleave', handleMouseLeave);
  324. }
  325. function loadImage(path) {
  326. return new Promise(resolve => {
  327. const img = new Image();
  328. img.onload = ev => resolve(img);
  329. img.onerror = ev => resolve(undefined);
  330. img.src = path || '';
  331. });
  332. }
  333. function getScorePercent(lovers, haters) {
  334. if (isNaN(lovers) || isNaN(haters))
  335. return undefined;
  336. return Math.round(100 * lovers / (lovers + haters));
  337. }
  338. function toDigitalTime(str) {
  339. if (!str)
  340. return undefined;
  341. function pad(s) {
  342. return s.length < 2 ? `0${s}` : s;
  343. }
  344. const hours = /(\d+)H/.exec(str);
  345. const mins = /(\d+)M/.exec(str);
  346. const secs = /(\d+)S/.exec(str);
  347. const output = [];
  348. if (hours)
  349. output.push(pad(hours[1]));
  350. output.push(mins ? pad(mins[1]) : '00');
  351. output.push(secs ? pad(secs[1]) : '00');
  352. return output.join(':');
  353. }
  354. function insertPeekBox(ev, d) {
  355. const a = ev.target;
  356. const settings = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS)));
  357. // Tokens:
  358. const title = d.snippet.localized.title;
  359. const desc = d.snippet.localized.description;
  360. const date = dateAsAge(d.snippet.publishedAt);
  361. const chan = d.snippet.channelTitle;
  362. const thumbs = d.snippet.thumbnails;
  363. const imagePath = thumbs.hasOwnProperty('medium') ? thumbs.medium.url : undefined;
  364. let blockMatched = [];
  365. let blockOther = [];
  366. if (settings.regions.length && d.contentDetails.hasOwnProperty('regionRestriction')) {
  367. const blocked = d.contentDetails.regionRestriction.blocked;
  368. if (blocked) {
  369. blockMatched = blocked.filter(v => settings.regions.includes(v)).map(v => `<em>${v}</em>`);
  370. blockOther = blocked.filter(v => !settings.regions.includes(v));
  371. }
  372. }
  373. const viewCount = +d.statistics.viewCount;
  374. const views = viewCount ? viewCount.toLocaleString() : undefined;
  375. const score = getScorePercent(+d.statistics.likeCount, +d.statistics.dislikeCount);
  376. const length = toDigitalTime(d.contentDetails.duration);
  377. loadImage(imagePath).then(img => {
  378. finishedLoading();
  379. if (!img)
  380. return;
  381. img.setAttribute('alt', title);
  382. container.querySelector('.yt-peek-thumb').appendChild(img);
  383. window.setTimeout(() => {
  384. img.classList.add('yt-peek-ready');
  385. }, 70);
  386. });
  387. // Create HTML:
  388. const container = document.createElement('div');
  389. container.innerHTML = `
  390. <div class="yt-peek-cols">
  391. <div class="yt-peek-info">
  392. <div class="yt-peek-row">
  393. <div class="yt-peek-views">${views ? views + ' views' : ''}</div>
  394. <div class="yt-peek-date">${date ? date : ''}</div>
  395. </div>
  396. <div class="yt-peek-title">${title ? title : `Not found`}</div>
  397. <div class="yt-peek-desc">${desc ? desc : ''}</div>
  398. <div class="yt-peek-chan">${chan ? chan : ''}</div>
  399. </div>
  400. <div class="yt-peek-preview">
  401. <div class="yt-peek-thumb"></div>
  402. <div class="yt-peek-loading yt-peek-ready"></div>
  403. ${length ? `<div class="yt-peek-length">${length}</div>` : ``}
  404. ${score ? `<div class="yt-peek-score"><div style="width: ${score}%;"></div></div>` : ``}
  405. </div>
  406. </div>
  407. ${blockMatched.length ? `<div class="yt-peek-blocked"><span>Blocked in:</span> ${blockMatched.join(' ')} ${blockOther.join(' ')}</div>` : ``}
  408. `;
  409. container.classList.add('yt-peek');
  410. if (!title) {
  411. container.classList.add('yt-peek-missing');
  412. }
  413. document.body.insertAdjacentElement('beforeend', container);
  414. // Clamp long lines of text:
  415. const $title = container.querySelector('.yt-peek-title');
  416. const $description = container.querySelector('.yt-peek-desc');
  417. $clamp($title, { clamp: 4, useNativeClamp: false });
  418. $clamp($description, { clamp: 4, useNativeClamp: false });
  419. // Find optimal position within viewport:
  420. setPosition(a, container);
  421. // Allow for smooth CSS transition:
  422. window.setTimeout(() => {
  423. container.classList.add('yt-peek-ready');
  424. }, 0);
  425. // Event listener to remove container because it shouldn't be interacted with:
  426. container.addEventListener('mouseenter', ev => {
  427. removePeekBoxes();
  428. });
  429. }
  430. function removePeekBoxes() {
  431. const elements = document.getElementsByClassName('yt-peek');
  432. for (const element of elements) {
  433. element.classList.remove('yt-peek-ready');
  434. // Allow for smooth CSS transition:
  435. window.setTimeout(() => {
  436. element.remove();
  437. }, 250);
  438. }
  439. }
  440. // Utility to check if a peek box is currently open in the document.
  441. function activePeekBox() {
  442. const elements = document.getElementsByClassName('yt-peek');
  443. if (elements.length)
  444. return elements[0];
  445. }
  446. function startedLoading(ev) {
  447. const indicator = document.createElement('div');
  448. indicator.classList.add('yt-peek-loading', 'yt-peek-ready');
  449. document.body.insertAdjacentElement('beforeend', indicator);
  450. setPosition(ev.target, indicator);
  451. }
  452. function finishedLoading() {
  453. const elements = document.getElementsByClassName('yt-peek-loading');
  454. for (const element of elements) {
  455. element.classList.remove('yt-peek-ready');
  456. window.setTimeout(() => {
  457. element.remove();
  458. }, 250);
  459. }
  460. }
  461. function handleSuccess(ev, id, d) {
  462. removePeekBoxes();
  463. if (!d) {
  464. d = {};
  465. d.id = id;
  466. d.contentDetails = {
  467. duration: undefined
  468. };
  469. d.snippet = {
  470. channelTitle: '',
  471. thumbnails: { medium: { url: undefined } },
  472. localized: {
  473. title: undefined,
  474. description: `The video might be removed.`
  475. },
  476. publishedAt: undefined
  477. };
  478. d.statistics = {};
  479. }
  480. insertPeekBox(ev, d);
  481. if (!cache.hasOwnProperty(id))
  482. cache[id] = d;
  483. }
  484. function requestVideoData(ev, id, parts) {
  485. startedLoading(ev);
  486. const xhr = new XMLHttpRequest();
  487. xhr.open('GET', `https://www.googleapis.com/youtube/v3/videos?id=${id}&part=${parts}&key=${apiKey}`);
  488. xhr.onreadystatechange = function () {
  489. if (xhr.readyState === 4) {
  490. finishedLoading();
  491. if (!tmo_open)
  492. return;
  493. if (!xhr.responseText.length)
  494. return;
  495. const response = JSON.parse(xhr.responseText);
  496. if (xhr.status === 200) {
  497. handleSuccess(ev, id, response.items[0]);
  498. }
  499. else {
  500. // handleError()
  501. }
  502. }
  503. else {
  504. finishedLoading();
  505. }
  506. };
  507. xhr.send();
  508. }
  509. function dateAsAge(inputValue) {
  510. if (!inputValue)
  511. return undefined;
  512. let date = new Date(inputValue);
  513. const difference = new Date(new Date().valueOf() - date.valueOf());
  514. let y = parseInt(difference.toISOString().slice(0, 4), 10) - 1970;
  515. let m = +difference.getMonth();
  516. let d = difference.getDate() - 1;
  517. let result;
  518. if (y > 0)
  519. result = (y === 1) ? y + ' year ago' : y + ' years ago';
  520. else if (m > 0)
  521. result = (m === 1) ? m + ' month ago' : m + ' months ago';
  522. else if (d > 0) {
  523. result = (d === 1) ? d + ' day ago' : d + ' days ago';
  524. }
  525. else {
  526. result = 'Today';
  527. }
  528. return result;
  529. }
  530. /**
  531. *
  532. * @param source Element to use for the relative position.
  533. * @param element The element to position.
  534. */
  535. function setPosition(source, element) {
  536. const srcRect = source.getBoundingClientRect();
  537. const clearanceHeight = element.clientHeight < 60 ? 60 : element.clientHeight;
  538. // Viewport dimensions:
  539. const vw = document.documentElement.clientWidth;
  540. const vh = document.documentElement.clientHeight;
  541. // Calculate:
  542. const leftOfTarget = vw < (srcRect.left + element.clientWidth);
  543. // Add extra space for browser status tooltip.
  544. const topOfTarget = vh < (srcRect.top + srcRect.height + clearanceHeight + 24);
  545. // Apply position:
  546. if (leftOfTarget) {
  547. element.style.right = vw - srcRect.right + 'px';
  548. }
  549. else {
  550. element.style.left = srcRect.left + 'px';
  551. }
  552. if (topOfTarget && (vh / 2 < srcRect.top)) {
  553. element.style.bottom = (vh - srcRect.top) - window.scrollY + 'px';
  554. }
  555. else {
  556. element.style.top = srcRect.bottom + window.scrollY + 'px';
  557. }
  558. }
  559. function insertSettingsDialog() {
  560. if (document.querySelector('.yt-peek-cfg'))
  561. return closeSettingsDialog();
  562. const data = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS)));
  563. const container = document.createElement('div');
  564. container.addEventListener('click', handleSettingsClick);
  565. container.classList.add('yt-peek-cfg');
  566. container.innerHTML = `
  567. <div class="yt-peek-cfg-heading">Youtube Peek</div>
  568. <div class="yt-peek-cfg-item">
  569. <label class="yt-peek-cfg-label" for="yt-peek-cfg-regions">Warn me if the video is blocked in:</label>
  570. <textarea id="yt-peek-cfg-regions">${data.regions.join(' ')}</textarea>
  571. <div class="yt-peek-cfg-desc">Space-separated list of region codes. E.g. US GB CA. Leave blank to ignore.</div>
  572. </div>
  573. <div class="yt-peek-cfg-item">
  574. <label>
  575. <input type="checkbox" id="yt-peek-cfg-noTooltip"${data.noTooltip ? ` checked` : ``}>
  576. Remove tooltips from video links
  577. </label>
  578. <div class="yt-peek-cfg-desc">Because tooltips can get in the way of the video preview.</div>
  579. </div>
  580. <div class="yt-peek-cfg-item">
  581. <label>
  582. <input type="checkbox" id="yt-peek-cfg-youtube"${data.allowOnYoutube ? ` checked` : ``}>
  583. Enable on youtube.com
  584. </label>
  585. <div class="yt-peek-cfg-desc">Peek isn't intended for use on youtube.com, but you can still use it there. (this change takes effect after reloading)</div>
  586. </div>
  587. <div class="yt-peek-cfg-footer">
  588. <button class="yt-peek-cfg-save" id="yt-peek-cfg-save">SAVE</button>
  589. <button class="yt-peek-cfg-cancel" id="yt-peek-cfg-cancel">CANCEL</button>
  590. </div>
  591. `;
  592. document.body.appendChild(container);
  593. }
  594. function handleSaveSettings() {
  595. const dialog = document.querySelector('.yt-peek-cfg');
  596. if (!dialog)
  597. return;
  598. // Retrieve values:
  599. const regionsInput = document.getElementById('yt-peek-cfg-regions');
  600. const noTooltipInput = document.getElementById('yt-peek-cfg-noTooltip');
  601. const allowOnYoutube = document.getElementById('yt-peek-cfg-youtube');
  602. // Format values:
  603. let regions = regionsInput.value.trim().replace(/\s\s+/g, ' ').toUpperCase();
  604. // Prepare data object for storage:
  605. const db_entry = {
  606. regions: regions.split(/\s/),
  607. noTooltip: noTooltipInput.checked,
  608. allowOnYoutube: allowOnYoutube.checked
  609. };
  610. GM_setValue('userSettings', JSON.stringify(db_entry));
  611. closeSettingsDialog();
  612. }
  613. function handleSettingsClick(ev) {
  614. if (ev.target.id === 'yt-peek-cfg-cancel') {
  615. closeSettingsDialog();
  616. }
  617. if (ev.target.id === 'yt-peek-cfg-save') {
  618. handleSaveSettings();
  619. }
  620. }
  621. function closeSettingsDialog() {
  622. const dialog = document.querySelector('.yt-peek-cfg');
  623. if (dialog)
  624. dialog.remove();
  625. }
  626. function handleMenuCommand() {
  627. insertSettingsDialog();
  628. }
  629. GM_registerMenuCommand('Youtube Peek Settings', handleMenuCommand);
  630. if (site('youtube.com') && !OPTIONS.allowOnYoutube)
  631. return;
  632. document.addEventListener('mouseover', handleMouseOver);
  633. })();