Vimeo Download Button

Adds a download button to the HTML5 Vimeo Player (embeded or not)

// ==UserScript==
// @name         Vimeo Download Button
// @namespace    larochematthias
// @version      1.2.1
// @description  Adds a download button to the HTML5 Vimeo Player (embeded or not)
// @author       Matthias Laroche
// @license https://creativecommons.org/licenses/by/4.0/
// @include      *//vimeo.com/*
// @include      *//player.vimeo.com/video/*
// @run-at       document-start
// @grant        none
// ==/UserScript==
(function() {
    'use strict';

    // SVG icon of the download button
    // Icon made by Elegant Themes from www.flaticon.com
    // Icon pack: http://www.flaticon.com/packs/elegant-font
    // Published by: https://www.elegantthemes.com/
    // License: https://creativecommons.org/licenses/by/3.0/
    var downloadIcon =
        '<svg viewBox="0 0 455.992 455.992" width="14px" height="14px">' +
        '<polygon class="fill" points="227.996,334.394 379.993,182.397 288.795,182.397 288.795,0 167.197,0 167.744,182.397 75.999,182.397" />' +
        '<polygon class="fill" points="349.594,334.394 349.594,395.193 106.398,395.193 106.398,334.394 45.599,334.394 45.599,395.193 45.599,455.992 410.393,455.992 410.393,334.394" />' +
        '</svg>';

    function updateButton(button, link) {
        if (!link) {
            button.div.style.visibility = 'hidden';
            return;
        }
        button.a.setAttribute('href', link.url);
        button.a.setAttribute('download', (link.title || '').replace(/[\x00-\x1F"*\/:<>?\\|]+/g, ''));
        button.a.setAttribute('title', 'Download ' + link.quality + "\n" + link.title);
        button.div.style.visibility = 'visible';
        button.div.style.display = 'block';
    }

    // Create HTML div element to add to the controls bar
    function createButton(document) {
        var div = document.createElement('div');
        div.style.marginLeft = '7px';
        div.style.marginTop = '-1px';
        div.style.display = 'none';
        div.setAttribute('class', 'download');
        var a = document.createElement('a');
        a.setAttribute('target', '_blank');
        a.setAttribute('aria-label', 'Download');
        a.setAttribute('referrerpolicy', 'origin');
        a.style.outlineStyle = 'none';
        a.innerHTML = downloadIcon;
        div.appendChild(a);
        return {
            div: div,
            a: a
        };
    }

    // Syntactic sugar for getting a single node with XPath
    function getSingleNode(node, xpath) {
        var doc = node.ownerDocument || node;
        return doc.evaluate(xpath, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
    }

    // Syntactic sugar for XMLHttpRequest
    function ajax(url, postData, onSuccess, onError) {
        var xhr = new XMLHttpRequest();
        xhr.open(postData ? 'POST' : 'GET', url);
        if (onSuccess || onError) {
            xhr.onreadystatechange = function() {
                if (xhr.readyState != 4) return;
                if (xhr.status == 200 && onSuccess) onSuccess(xhr);
                if (xhr.status != 200 && onError) onError(xhr);
            };
        }
        xhr.send(postData || null);
    }


    // Get url, quality and title of the video from the config of the player
    // and then update the download button accordingly
    function updateVideoLink(button, config) {
        if (config && typeof(config) === 'object') {
            var title = config && config.video && config.video.title || '';
            var progressive = config && config.request && config.request.files && config.request.files.progressive;
            if (progressive) {
                var file = progressive.reduce(function(a, b) {
                    return (b.width || 0) > a.width ? b : a;
                }, {
                    width: -1
                });
                if (file.url) {
                    updateButton(button, {
                        url: file.url,
                        title: title,
                        quality: file.quality
                    });
                    return;
                }
            }
        }
        updateButton(button, null);
        if (config && typeof(config) === 'string') {
            // config is an URL -> download with ajax and update again with the result
            ajax(config, null, function(xhr) {
                updateVideoLink(button, JSON.parse(xhr.responseText));
            });
        }
    }

    // Called by the MutationObserver, keep adding the download button to the DOM if it disappears
    function showVideoLink(button, controls) {
        var playBar = getSingleNode(controls, "//div[contains(@class, 'play-bar')]");
        if (playBar && (button.div.parentNode != playBar || button.div.nextSibling)) {
            playBar.appendChild(button.div);
        }
    }

    // Create a link and update it with the config of the player,
    // set a MutationObserver to add the download button when the controls are ready
    // and wrap the player to intercept the loadVideo method and update the link
    function wrapVimeoPlayer(player, container, config) {
        if (!player || !container) return player;
        var controls = getSingleNode(container, "//div[@class = 'controls-wrapper']//div[@class = 'controls']");
        if (!controls) return player;
        var button = createButton(container.ownerDocument);
        var observer = new MutationObserver(function() {
            showVideoLink(button, controls);
        });
        observer.observe(controls, {
            childList: true,
            subtree: true,
            attributes: false,
            characterData: false
        });
        updateVideoLink(button, config);
        showVideoLink(button, controls);
        return Object.create(player, {
            ready: {
                writable: true,
                enumerable: true,
                configurable: true,
                value: player.ready
            },
            loadVideo: {
                enumerable: true,
                configurable: false,
                get: function() {
                    return player.loadVideo && function() {
                        updateVideoLink(button, arguments && arguments[0]);
                        return player.loadVideo.apply(this, arguments);
                    };
                }
            }
        });
    }

    // Wraps a property with a callback that convert the value each time the property is set
    function wrapProperty(o, propName, callback) {
        if (o.hasOwnProperty(propName)) {
            console.log('Vimeo Download Button : Unable to wrap property ' + propName + '.');
            return;
        }
        var value;
        Object.defineProperty(o, propName, {
            get: function() {
                return value;
            },
            set: function(newValue) {
                value = callback(newValue);
            },
            enumerable: true,
            configurable: false
        });
        console.log('Vimeo Download Button : Property ' + propName + ' wrapped successfully.');
    }

    // Wrap the VimeoPlayer constructor and intercepts the arguments needed to install the download button
    wrapProperty(window, 'VimeoPlayer', function(ctr) {
        return ctr && function() {
            var container = arguments && arguments[0];
            var config = arguments && arguments[1];
            var player = ctr.apply(this, arguments);
            return wrapVimeoPlayer(player, container, config);
        };
    });
})();