YouTube Playlist Duration Sort

Add duration sorting options to YouTube playlists.

As of 2020-10-07. See the latest version.

// ==UserScript==
// @name         YouTube Playlist Duration Sort
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Add duration sorting options to YouTube playlists.
// @author       Surf Archer
// @icon         https://www.youtube.com/favicon.ico
// @match        https://www.youtube.com/playlist?*
// @grant        unsafeWindow
// ==/UserScript==


(function() {
    'use strict';

    window.addEventListener("load", () => {
        addSortMenuItem("Duration (shortest)", "durationSortClickShortest");
        addSortMenuItem("Duration (longest)", "durationSortClickLongest");
        injectJS();
    });

    function addSortMenuItem(text, handler) {
        var sfm = document.querySelector("#sort-filter-menu");
        var submenu = sfm.querySelector("#menu");

        var d1=document.createElement("DIV");
        d1.setAttribute("class", "item style-scope yt-dropdown-menu");
        d1.textContent=text;

        var d2=document.createElement("DIV");
        d2.setAttribute("secondary", "");
        d2.setAttribute("id", "subtitle");
        d2.setAttribute("class", "style-scope yt-dropdown-menu");
        d2.setAttribute("hidden", "");

        var pib=document.createElement("paper-item-body");
        pib.setAttribute("class", "style-scope yt-dropdown-menu");
        pib.appendChild(d1);
        pib.appendChild(d2);

        var pi=document.createElement("paper-item");
        pi.setAttribute("class", "style-scope yt-dropdown-menu");
        pi.setAttribute("role", "option");
        pi.tabindex="0";
        pi.appendChild(pib);

        var e=document.createElement("A");
        e.setAttribute("class", "yt-simple-endpoint style-scope yt-dropdown-menu");
        e.setAttribute("id", handler);
        e.setAttribute("tabindex", "-1");
        e.appendChild(pi);

        submenu.appendChild(e);
    }

    function injectJS() {
        logMsg("Injecting Javascript...");

        var script = document.createElement("script");
        script.type = "application/javascript";
        script.textContent = "(" + injectScript + ")();";

        document.body.appendChild(script);

        logMsg("Javascript injected!");
    }

    function injectScript() {
        logMsg("Initialising durationSort...");

        const debug = false;
        var duratonSortRunning = false;

        document.getElementById('durationSortClickShortest').onclick = durationSortClickShortest;
        document.getElementById('durationSortClickLongest').onclick = durationSortClickLongest;

        document.addEventListener('ytplDurationSortEvent', durationSortEvent);

        function durationSortClickShortest() {
            logMsg('durationSortClickShortest')

            setTimeout(function(){
                var event = new CustomEvent('ytplDurationSortEvent', {
                    detail: {
                        operation: "init", order: 0
                    }
                });
                document.dispatchEvent(event);
            }, 50);
        }

        function durationSortClickLongest() {
            logMsg('durationSortClickLongest')

            setTimeout(function(){
                var event = new CustomEvent('ytplDurationSortEvent', {
                    detail: {
                        operation: "init", order: 1
                    }
                });
                document.dispatchEvent(event);
            }, 50);
        }


        function durationSortEvent(e) {
            logDebug("durationSortEvent("+e+")");
            switch(e.detail.operation) {
                case "init":
                    // De-click the menu.
                    setTimeout(function(){
                        var elemMenu = document.querySelector("#sort-filter-menu > yt-sort-filter-sub-menu-renderer > yt-dropdown-menu > paper-menu-button");
                        eventFire(elemMenu, 'click')
                    }, 64);

                    // Then initiate the sort.
                    e.detail.operation = "sort";
                    setTimeout(function(){
                        var event = new CustomEvent('ytplDurationSortEvent', {
                            detail: e.detail
                        });
                        document.dispatchEvent(event);
                    }, 128);
                    break;
                case "scroll-to-end":
                    loadPlaylist(e);
                    break;
                case "sort":
                    sortPlaylist(e);
                    break;
            }
        }

        function loadPlaylist(e) {
            logMsg(" - loadPlaylist() starting");
            var playList = document.querySelector("#contents > ytd-playlist-video-renderer:nth-child(1)").parentNode;
            var numChildren = playList.children.length;
            logDebug("   - Videos in list: "+numChildren);
            if(numChildren > 0) {
                logDebug("   - Scrolling to last video...");
                document.querySelector("#contents > ytd-playlist-video-renderer:nth-child("+numChildren+")").scrollIntoView(true);

                // Then initiate the sort.
                setTimeout(function(){
                    var event = new CustomEvent('ytplDurationSortEvent', {
                        detail: { operation: "sort", order: e.detail.order }
                    });
                    document.dispatchEvent(event);
                }, 256);
            }
            logMsg(" - loadPlaylist() finished");
        }

        function sortPlaylist(e) {
            logDebug(" - sortPlaylist("+JSON.stringify(e.detail)+") starting");

            var numInList=document.querySelector("#contents > ytd-playlist-video-renderer:nth-child(1)").parentNode.children.length;
            for (var i = 1; numInList > 1 && i < numInList; i++) {
                var nInd=i + 1;
                var nSecs=parseInt(getRowVal(i, "lengthSeconds"));

                var nPrevInd=0;
                var nMoveTo=-1;
                while(nPrevInd < nInd && nMoveTo == -1) {
                    var nPrevSecs=parseInt(getRowVal(nPrevInd, "lengthSeconds"));
                    if(nPrevSecs !== undefined) {
                        if(e.detail.order == 0 && nPrevSecs > nSecs) {
                            nMoveTo=nPrevInd;
                        }
                        if(e.detail.order == 1 && nPrevSecs < nSecs) {
                            nMoveTo=nPrevInd;
                        }
                    }
                    nPrevInd++;
                }

                if(nMoveTo > -1) {
                    nMoveTo++;
                    logDebug(" - MOVING row "+nInd+" ("+nSecs+" secs) to row "+nMoveTo+" ("+nPrevSecs+" secs)", true);
                    moveRowTo(nInd, nMoveTo);

                    if(i < (numInList - 1)) {
                        // Then send the next sort.
                        setTimeout(function(){
                            var event = new CustomEvent('ytplDurationSortEvent', {
                                detail: e.detail
                            });
                            document.dispatchEvent(event);
                        }, 512);
                        return;
                    }
                } else {
                    logDebug(" - Ignoring row "+nInd+" (duration: "+nSecs+", prevDuration: "+nPrevSecs+")");
                }
            }

            logDebug(" - sortPlaylist("+e.detail.order+") finished");
        }

        function getRowVal(index, key) {
            var row=document.querySelector("#contents > ytd-playlist-video-renderer:nth-child(1)").parentNode.children[index];
            if((key in row.data)) {
                return row.data[key];
            }
        }

        function moveRowTo(src, dest) {
            triggerDragAndDrop("#contents > ytd-playlist-video-renderer:nth-child("+src+")", "#contents > ytd-playlist-video-renderer:nth-child("+dest+")");
        }

        function fireMouseEvent(type, elem, centerX, centerY) {
            var evt = document.createEvent('MouseEvents');
            evt.initMouseEvent(type, true, true, window, 1, 1, 1, centerX, centerY, false, false, false, false, 0, elem);
            elem.dispatchEvent(evt);
            //document.elem.dispatchEvent(evt);
        };

        function eventFire(el, etype){
            if (el.fireEvent) {
                el.fireEvent('on' + etype);
            } else {
                var evObj = document.createEvent('Events');
                evObj.initEvent(etype, true, false);
                el.dispatchEvent(evObj);
            }
        };

        function triggerDragAndDrop(selectorDrag, selectorDrop) {
            // fetch target elements
            var elemDrag = document.querySelector(selectorDrag);
            var elemDrop = document.querySelector(selectorDrop);
            if (!elemDrag || !elemDrop) return false;

            // calculate positions
            var pos = elemDrag.getBoundingClientRect();
            var center1X = Math.floor((pos.left + pos.right) / 2);
            var center1Y = Math.floor((pos.top + pos.bottom) / 2);
            pos = elemDrop.getBoundingClientRect();
            var center2X = Math.floor((pos.left + pos.right) / 2);
            var center2Y = Math.floor((pos.top + pos.bottom) / 2);

            // mouse over dragged element and mousedown
            fireMouseEvent('mousemove', elemDrag, center1X, center1Y);
            wait(10);
            fireMouseEvent('mouseenter', elemDrag, center1X, center1Y);
            wait(10);
            fireMouseEvent('mouseover', elemDrag, center1X, center1Y);
            wait(10);
            fireMouseEvent('mousedown', elemDrag, center1X, center1Y);
            wait(30);

            // start dragging process over to drop target
            fireMouseEvent('dragstart', elemDrag, center1X, center1Y);
            wait(10);
            fireMouseEvent('drag', elemDrag, center1X, center1Y);
            wait(10);
            fireMouseEvent('mousemove', elemDrag, center1X, center1Y);
            wait(10);
            fireMouseEvent('drag', elemDrag, center2X, center2Y);
            wait(10);
            fireMouseEvent('mousemove', elemDrop, center2X, center2Y);
            wait(30);

            // trigger dragging process on top of drop target
            fireMouseEvent('mouseenter', elemDrop, center2X, center2Y);
            wait(10);
            fireMouseEvent('dragenter', elemDrop, center2X, center2Y);
            wait(10);
            fireMouseEvent('mouseover', elemDrop, center2X, center2Y);
            wait(10);
            fireMouseEvent('dragover', elemDrop, center2X, center2Y);
            wait(30);

            // release dragged element on top of drop target
            fireMouseEvent('drop', elemDrop, center2X, center2Y);
            wait(10);
            fireMouseEvent('dragend', elemDrag, center2X, center2Y);
            wait(10);
            fireMouseEvent('mouseup', elemDrag, center2X, center2Y);
            wait(150);

            return true;
        }

        function logMsg(msg) {
            console.log("[yt-pl-duration-sort] "+msg);
        }

        function logDebug(msg, force=false) {
            if(debug || force) {
                console.debug("[yt-pl-duration-sort] "+msg);
            }
        }

        function wait(ms){
            var start = new Date().getTime();
            var end = start;
            while(end < start + ms) {
                end = new Date().getTime();
            }
        }

        logMsg("Initialisation finished.");
    }

    function logMsg(msg) {
        console.log("[yt-pl-duration-sort] "+msg);
    }

    function simulateKey (keyCode, type, modifiers) {
        var evtName = (typeof(type) === "string") ? "key" + type : "keydown";
        var modifier = (typeof(modifiers) === "object") ? modifier : {};

        var event = document.createEvent("HTMLEvents");
        event.initEvent(evtName, true, false);
        event.keyCode = keyCode;

        for (var i in modifiers) {
            event[i] = modifiers[i];
        }

        document.dispatchEvent(event);
    }


})();