Youtube Peek Preview

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

// ==UserScript==
// @name         Youtube Peek Preview
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  See video thumbnails, ratings and other details when you mouse over a Youtube link from almost any website
// @author       scriptpost
// @match        *://*/*
// @exclude      https://twitter.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// ==/UserScript==
(function () {
    // Remove deprecated storage structure from earlier versions.
    // Configure settings through your browser extension icon, under "Youtube Peek Settings"
    const settings = JSON.parse(GM_getValue('userSettings', '{}'));
    if (settings.hasOwnProperty('REGIONS')) {
        GM_deleteValue('userSettings');
    }
})();
/*!
* Clamp.js 0.5.1
*
* Copyright 2011-2013, Joseph Schmitt http://joe.sh
* Released under the WTFPL license
* http://sam.zoy.org/wtfpl/
*/
(function () {
    /**
     * Clamps a text node.
     * @param {HTMLElement} element. Element containing the text node to clamp.
     * @param {Object} options. Options to pass to the clamper.
     */
    function clamp(element, options) {
        options = options || {};
        var self = this, win = window, opt = {
            clamp: options.clamp || 2,
            useNativeClamp: typeof (options.useNativeClamp) != 'undefined' ? options.useNativeClamp : true,
            splitOnChars: options.splitOnChars || ['.', '-', '–', '—', ' '],
            animate: options.animate || false,
            truncationChar: options.truncationChar || '…',
            truncationHTML: options.truncationHTML
        }, 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;
        if (opt.truncationHTML) {
            truncationHTMLContainer = document.createElement('span');
            truncationHTMLContainer.innerHTML = opt.truncationHTML;
        }
        // UTILITY FUNCTIONS
        /**
         * Return the current style for an element.
         * @param {HTMLElement} elem The element to compute.
         * @param {string} prop The style property.
         * @returns {number}
         */
        function computeStyle(elem, prop) {
            if (!win.getComputedStyle) {
                win.getComputedStyle = function (el, pseudo) {
                    this.el = el;
                    this.getPropertyValue = function (prop) {
                        var re = /(\-([a-z]){1})/g;
                        if (prop == 'float')
                            prop = 'styleFloat';
                        if (re.test(prop)) {
                            prop = prop.replace(re, function () {
                                return arguments[2].toUpperCase();
                            });
                        }
                        return el.currentStyle && el.currentStyle[prop] ? el.currentStyle[prop] : null;
                    };
                    return this;
                };
            }
            return win.getComputedStyle(elem, null).getPropertyValue(prop);
        }
        /**
         * Returns the maximum number of lines of text that should be rendered based
         * on the current height of the element and the line-height of the text.
         */
        function getMaxLines(height) {
            var availHeight = height || element.clientHeight, lineHeight = getLineHeight(element);
            return Math.max(Math.floor(availHeight / lineHeight), 0);
        }
        /**
         * Returns the maximum height a given element should have based on the line-
         * height of the text and the given clamp value.
         */
        function getMaxHeight(clmp) {
            var lineHeight = getLineHeight(element);
            return lineHeight * clmp;
        }
        /**
         * Returns the line-height of an element as an integer.
         */
        function getLineHeight(elem) {
            var lh = computeStyle(elem, 'line-height');
            if (lh == 'normal') {
                // Normal line heights vary from browser to browser. The spec recommends
                // a value between 1.0 and 1.2 of the font size. Using 1.1 to split the diff.
                lh = parseInt(computeStyle(elem, 'font-size')) * 1.2;
            }
            return parseInt(lh);
        }
        // MEAT AND POTATOES (MMMM, POTATOES...)
        var splitOnChars = opt.splitOnChars.slice(0), splitChar = splitOnChars[0], chunks, lastChunk;
        /**
         * Gets an element's last child. That may be another node or a node's contents.
         */
        function getLastChild(elem) {
            //Current element has children, need to go deeper and get last child as a text node
            if (elem.lastChild.children && elem.lastChild.children.length > 0) {
                return getLastChild(Array.prototype.slice.call(elem.children).pop());
            }
            //This is the absolute last child, a text node, but something's wrong with it. Remove it and keep trying
            else if (!elem.lastChild || !elem.lastChild.nodeValue || elem.lastChild.nodeValue == '' || elem.lastChild.nodeValue == opt.truncationChar) {
                elem.lastChild.parentNode.removeChild(elem.lastChild);
                return getLastChild(element);
            }
            //This is the last child we want, return it
            else {
                return elem.lastChild;
            }
        }
        /**
         * Removes one character at a time from the text until its width or
         * height is beneath the passed-in max param.
         */
        function truncate(target, maxHeight) {
            if (!maxHeight) {
                return;
            }
            /**
             * Resets global variables.
             */
            function reset() {
                splitOnChars = opt.splitOnChars.slice(0);
                splitChar = splitOnChars[0];
                chunks = null;
                lastChunk = null;
            }
            var nodeValue = target.nodeValue.replace(opt.truncationChar, '');
            //Grab the next chunks
            if (!chunks) {
                //If there are more characters to try, grab the next one
                if (splitOnChars.length > 0) {
                    splitChar = splitOnChars.shift();
                }
                //No characters to chunk by. Go character-by-character
                else {
                    splitChar = '';
                }
                chunks = nodeValue.split(splitChar);
            }
            //If there are chunks left to remove, remove the last one and see if
            // the nodeValue fits.
            if (chunks.length > 1) {
                // console.log('chunks', chunks);
                lastChunk = chunks.pop();
                // console.log('lastChunk', lastChunk);
                applyEllipsis(target, chunks.join(splitChar));
            }
            //No more chunks can be removed using this character
            else {
                chunks = null;
            }
            //Insert the custom HTML before the truncation character
            if (truncationHTMLContainer) {
                target.nodeValue = target.nodeValue.replace(opt.truncationChar, '');
                element.innerHTML = target.nodeValue + ' ' + truncationHTMLContainer.innerHTML + opt.truncationChar;
            }
            //Search produced valid chunks
            if (chunks) {
                //It fits
                if (element.clientHeight <= maxHeight) {
                    //There's still more characters to try splitting on, not quite done yet
                    if (splitOnChars.length >= 0 && splitChar != '') {
                        applyEllipsis(target, chunks.join(splitChar) + splitChar + lastChunk);
                        chunks = null;
                    }
                    //Finished!
                    else {
                        return element.innerHTML;
                    }
                }
            }
            //No valid chunks produced
            else {
                //No valid chunks even when splitting by letter, time to move
                //on to the next node
                if (splitChar == '') {
                    applyEllipsis(target, '');
                    target = getLastChild(element);
                    reset();
                }
            }
            //If you get here it means still too big, let's keep truncating
            if (opt.animate) {
                setTimeout(function () {
                    truncate(target, maxHeight);
                }, opt.animate === true ? 10 : opt.animate);
            }
            else {
                return truncate(target, maxHeight);
            }
        }
        function applyEllipsis(elem, str) {
            elem.nodeValue = str + opt.truncationChar;
        }
        // CONSTRUCTOR
        if (clampValue == 'auto') {
            clampValue = getMaxLines();
        }
        else if (isCSSValue) {
            clampValue = getMaxLines(parseInt(clampValue));
        }
        var clampedText;
        if (supportsNativeClamp && opt.useNativeClamp) {
            sty.overflow = 'hidden';
            sty.textOverflow = 'ellipsis';
            sty.webkitBoxOrient = 'vertical';
            sty.display = '-webkit-box';
            sty.webkitLineClamp = clampValue;
            if (isCSSValue) {
                sty.height = opt.clamp + 'px';
            }
        }
        else {
            var height = getMaxHeight(clampValue);
            if (height <= element.clientHeight) {
                clampedText = truncate(getLastChild(element), height);
            }
        }
        return {
            'original': originalText,
            'clamped': clampedText
        };
    }
    window.$clamp = clamp;
})();
(function () {
    // Begin script: Youtube Peek
    'use strict';
    const DEFAULT_OPTIONS = {
        regions: [],
        noTooltip: true,
        allowOnYoutube: false
    };
    const OPTIONS = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS)));
    const apiKey = 'AIzaSyBnibVlVDGC7t_wd3ZErVK6XF3hp3G7xtA';
    const re = {
        isVideoLink: /(?:youtube\.com\/(?:watch\?.*v=|attribution_link)|youtu\.be\/|y2u\.be\/)/i,
        getVideoId: /(?:youtube\.com\/watch\?.*v=|youtu\.be\/|y2u\.be\/)([-_A-Za-z0-9]{11})/i,
        getTimeLength: /\d+[A-Z]/g,
    };
    const cache = {};
    const delay_open = 100;
    const delay_close = 0;
    let tmo_open;
    let tmo_close;
    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>`;
    document.body.insertAdjacentHTML('beforeend', _stylesheet);
    function containsEncodedComponents(x) {
        return (decodeURI(x) !== decodeURIComponent(x));
    }
    /**
     * Check if we're on a particular domain name.
     * @param host Name of the website.
     */
    function site(host) {
        return window.location.host.includes(host);
    }
    function handleMouseOver(ev) {
        let target = ev.target;
        target = target.closest('a');
        if (!target)
            return;
        let href = target.href;
        if (!href)
            return;
        // Some sites put the URL in a dataset. (note: twitter blocks goog API)
        if (site('twitter.com')) {
            const dataUrl = target.dataset.expandedUrl;
            if (dataUrl)
                href = dataUrl;
        }
        // Check if the URL goes to a youtube video.
        if (!re.isVideoLink.test(href))
            return;
        // Need to know if it's an attribution link so we can read the encoded params.
        if (/attribution_link\?/i.test(href)) {
            const URIComponent = href.substr(href.indexOf('%2Fwatch%3Fv%3D'));
            if (containsEncodedComponents(URIComponent)) {
                href = 'https://www.youtube.com' + decodeURIComponent(URIComponent);
            }
        }
        // Finally get the video ID;
        const id = re.getVideoId.exec(href)[1];
        if (!id)
            return console.error('Invalid video ID');
        window.clearTimeout(tmo_open);
        window.clearTimeout(tmo_close);
        const noTooltip = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS))).noTooltip;
        if (noTooltip) {
            target.removeAttribute('title');
        }
        tmo_open = window.setTimeout(() => {
            if (!cache.hasOwnProperty(id)) {
                const parts = 'snippet,contentDetails,statistics';
                requestVideoData(ev, id, parts);
            }
            else {
                handleSuccess(ev, id, cache[id]);
            }
        }, delay_open);
        function handleMouseLeave(ev) {
            target.removeEventListener('mouseleave', handleMouseLeave);
            window.clearTimeout(tmo_open);
            tmo_open = null;
            tmo_close = window.setTimeout(() => {
                removePeekBoxes();
            }, delay_close);
        }
        target.addEventListener('mouseleave', handleMouseLeave);
    }
    function loadImage(path) {
        return new Promise(resolve => {
            const img = new Image();
            img.onload = ev => resolve(img);
            img.onerror = ev => resolve(undefined);
            img.src = path || '';
        });
    }
    function getScorePercent(lovers, haters) {
        if (isNaN(lovers) || isNaN(haters))
            return undefined;
        return Math.round(100 * lovers / (lovers + haters));
    }
    function toDigitalTime(str) {
        if (!str)
            return undefined;
        function pad(s) {
            return s.length < 2 ? `0${s}` : s;
        }
        const hours = /(\d+)H/.exec(str);
        const mins = /(\d+)M/.exec(str);
        const secs = /(\d+)S/.exec(str);
        const output = [];
        if (hours)
            output.push(pad(hours[1]));
        output.push(mins ? pad(mins[1]) : '00');
        output.push(secs ? pad(secs[1]) : '00');
        return output.join(':');
    }
    function insertPeekBox(ev, d) {
        const a = ev.target;
        const settings = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS)));
        // Tokens:
        const title = d.snippet.localized.title;
        const desc = d.snippet.localized.description;
        const date = dateAsAge(d.snippet.publishedAt);
        const chan = d.snippet.channelTitle;
        const thumbs = d.snippet.thumbnails;
        const imagePath = thumbs.hasOwnProperty('medium') ? thumbs.medium.url : undefined;
        let blockMatched = [];
        let blockOther = [];
        if (settings.regions.length && d.contentDetails.hasOwnProperty('regionRestriction')) {
            const blocked = d.contentDetails.regionRestriction.blocked;
            if (blocked) {
                blockMatched = blocked.filter(v => settings.regions.includes(v)).map(v => `<em>${v}</em>`);
                blockOther = blocked.filter(v => !settings.regions.includes(v));
            }
        }
        const viewCount = +d.statistics.viewCount;
        const views = viewCount ? viewCount.toLocaleString() : undefined;
        const score = getScorePercent(+d.statistics.likeCount, +d.statistics.dislikeCount);
        const length = toDigitalTime(d.contentDetails.duration);
        loadImage(imagePath).then(img => {
            finishedLoading();
            if (!img)
                return;
            img.setAttribute('alt', title);
            container.querySelector('.yt-peek-thumb').appendChild(img);
            window.setTimeout(() => {
                img.classList.add('yt-peek-ready');
            }, 70);
        });
        // Create HTML:
        const container = document.createElement('div');
        container.innerHTML = `
    <div class="yt-peek-cols">
      <div class="yt-peek-info">
        <div class="yt-peek-row">
          <div class="yt-peek-views">${views ? views + ' views' : ''}</div>
          <div class="yt-peek-date">${date ? date : ''}</div>
        </div>
        <div class="yt-peek-title">${title ? title : `Not found`}</div>
        <div class="yt-peek-desc">${desc ? desc : ''}</div>
        <div class="yt-peek-chan">${chan ? chan : ''}</div>
      </div>
      <div class="yt-peek-preview">
        <div class="yt-peek-thumb"></div>
        <div class="yt-peek-loading yt-peek-ready"></div>
        ${length ? `<div class="yt-peek-length">${length}</div>` : ``}
        ${score ? `<div class="yt-peek-score"><div style="width: ${score}%;"></div></div>` : ``}
      </div>
    </div>
    ${blockMatched.length ? `<div class="yt-peek-blocked"><span>Blocked in:</span> ${blockMatched.join(' ')} ${blockOther.join(' ')}</div>` : ``}
    `;
        container.classList.add('yt-peek');
        if (!title) {
            container.classList.add('yt-peek-missing');
        }
        document.body.insertAdjacentElement('beforeend', container);
        // Clamp long lines of text:
        const $title = container.querySelector('.yt-peek-title');
        const $description = container.querySelector('.yt-peek-desc');
        $clamp($title, { clamp: 4, useNativeClamp: false });
        $clamp($description, { clamp: 4, useNativeClamp: false });
        // Find optimal position within viewport:
        setPosition(a, container);
        // Allow for smooth CSS transition:
        window.setTimeout(() => {
            container.classList.add('yt-peek-ready');
        }, 0);
        // Event listener to remove container because it shouldn't be interacted with:
        container.addEventListener('mouseenter', ev => {
            removePeekBoxes();
        });
    }
    function removePeekBoxes() {
        const elements = document.getElementsByClassName('yt-peek');
        for (const element of elements) {
            element.classList.remove('yt-peek-ready');
            // Allow for smooth CSS transition:
            window.setTimeout(() => {
                element.remove();
            }, 250);
        }
    }
    // Utility to check if a peek box is currently open in the document.
    function activePeekBox() {
        const elements = document.getElementsByClassName('yt-peek');
        if (elements.length)
            return elements[0];
    }
    function startedLoading(ev) {
        const indicator = document.createElement('div');
        indicator.classList.add('yt-peek-loading', 'yt-peek-ready');
        document.body.insertAdjacentElement('beforeend', indicator);
        setPosition(ev.target, indicator);
    }
    function finishedLoading() {
        const elements = document.getElementsByClassName('yt-peek-loading');
        for (const element of elements) {
            element.classList.remove('yt-peek-ready');
            window.setTimeout(() => {
                element.remove();
            }, 250);
        }
    }
    function handleSuccess(ev, id, d) {
        removePeekBoxes();
        if (!d) {
            d = {};
            d.id = id;
            d.contentDetails = {
                duration: undefined
            };
            d.snippet = {
                channelTitle: '',
                thumbnails: { medium: { url: undefined } },
                localized: {
                    title: undefined,
                    description: `The video might be removed.`
                },
                publishedAt: undefined
            };
            d.statistics = {};
        }
        insertPeekBox(ev, d);
        if (!cache.hasOwnProperty(id))
            cache[id] = d;
    }
    function requestVideoData(ev, id, parts) {
        startedLoading(ev);
        const xhr = new XMLHttpRequest();
        xhr.open('GET', `https://www.googleapis.com/youtube/v3/videos?id=${id}&part=${parts}&key=${apiKey}`);
        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4) {
                finishedLoading();
                if (!tmo_open)
                    return;
                if (!xhr.responseText.length)
                    return;
                const response = JSON.parse(xhr.responseText);
                if (xhr.status === 200) {
                    handleSuccess(ev, id, response.items[0]);
                }
                else {
                    // handleError()
                }
            }
            else {
                finishedLoading();
            }
        };
        xhr.send();
    }
    function dateAsAge(inputValue) {
        if (!inputValue)
            return undefined;
        let date = new Date(inputValue);
        const difference = new Date(new Date().valueOf() - date.valueOf());
        let y = parseInt(difference.toISOString().slice(0, 4), 10) - 1970;
        let m = +difference.getMonth();
        let d = difference.getDate() - 1;
        let result;
        if (y > 0)
            result = (y === 1) ? y + ' year ago' : y + ' years ago';
        else if (m > 0)
            result = (m === 1) ? m + ' month ago' : m + ' months ago';
        else if (d > 0) {
            result = (d === 1) ? d + ' day ago' : d + ' days ago';
        }
        else {
            result = 'Today';
        }
        return result;
    }
    /**
     *
     * @param source Element to use for the relative position.
     * @param element The element to position.
     */
    function setPosition(source, element) {
        const srcRect = source.getBoundingClientRect();
        const clearanceHeight = element.clientHeight < 60 ? 60 : element.clientHeight;
        // Viewport dimensions:
        const vw = document.documentElement.clientWidth;
        const vh = document.documentElement.clientHeight;
        // Calculate:
        const leftOfTarget = vw < (srcRect.left + element.clientWidth);
        // Add extra space for browser status tooltip.
        const topOfTarget = vh < (srcRect.top + srcRect.height + clearanceHeight + 24);
        // Apply position:
        if (leftOfTarget) {
            element.style.right = vw - srcRect.right + 'px';
        }
        else {
            element.style.left = srcRect.left + 'px';
        }
        if (topOfTarget && (vh / 2 < srcRect.top)) {
            element.style.bottom = (vh - srcRect.top) - window.scrollY + 'px';
        }
        else {
            element.style.top = srcRect.bottom + window.scrollY + 'px';
        }
    }
    function insertSettingsDialog() {
        if (document.querySelector('.yt-peek-cfg'))
            return closeSettingsDialog();
        const data = JSON.parse(GM_getValue('userSettings', JSON.stringify(DEFAULT_OPTIONS)));
        const container = document.createElement('div');
        container.addEventListener('click', handleSettingsClick);
        container.classList.add('yt-peek-cfg');
        container.innerHTML = `
      <div class="yt-peek-cfg-heading">Youtube Peek</div>
      <div class="yt-peek-cfg-item">
        <label class="yt-peek-cfg-label" for="yt-peek-cfg-regions">Warn me if the video is blocked in:</label>
        <textarea id="yt-peek-cfg-regions">${data.regions.join(' ')}</textarea>
        <div class="yt-peek-cfg-desc">Space-separated list of region codes. E.g. US GB CA. Leave blank to ignore.</div>
      </div>
      <div class="yt-peek-cfg-item">
        <label>
          <input type="checkbox" id="yt-peek-cfg-noTooltip"${data.noTooltip ? ` checked` : ``}>
          Remove tooltips from video links
        </label>
        <div class="yt-peek-cfg-desc">Because tooltips can get in the way of the video preview.</div>
      </div>
      <div class="yt-peek-cfg-item">
        <label>
          <input type="checkbox" id="yt-peek-cfg-youtube"${data.allowOnYoutube ? ` checked` : ``}>
          Enable on youtube.com
        </label>
        <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>
      </div>
      <div class="yt-peek-cfg-footer">
        <button class="yt-peek-cfg-save" id="yt-peek-cfg-save">SAVE</button>
        <button class="yt-peek-cfg-cancel" id="yt-peek-cfg-cancel">CANCEL</button>
      </div>
    `;
        document.body.appendChild(container);
    }
    function handleSaveSettings() {
        const dialog = document.querySelector('.yt-peek-cfg');
        if (!dialog)
            return;
        // Retrieve values:
        const regionsInput = document.getElementById('yt-peek-cfg-regions');
        const noTooltipInput = document.getElementById('yt-peek-cfg-noTooltip');
        const allowOnYoutube = document.getElementById('yt-peek-cfg-youtube');
        // Format values:
        let regions = regionsInput.value.trim().replace(/\s\s+/g, ' ').toUpperCase();
        // Prepare data object for storage:
        const db_entry = {
            regions: regions.split(/\s/),
            noTooltip: noTooltipInput.checked,
            allowOnYoutube: allowOnYoutube.checked
        };
        GM_setValue('userSettings', JSON.stringify(db_entry));
        closeSettingsDialog();
    }
    function handleSettingsClick(ev) {
        if (ev.target.id === 'yt-peek-cfg-cancel') {
            closeSettingsDialog();
        }
        if (ev.target.id === 'yt-peek-cfg-save') {
            handleSaveSettings();
        }
    }
    function closeSettingsDialog() {
        const dialog = document.querySelector('.yt-peek-cfg');
        if (dialog)
            dialog.remove();
    }
    function handleMenuCommand() {
        insertSettingsDialog();
    }
    GM_registerMenuCommand('Youtube Peek Settings', handleMenuCommand);
    if (site('youtube.com') && !OPTIONS.allowOnYoutube)
        return;
    document.addEventListener('mouseover', handleMouseOver);
})();