YouTube Playlist Duration Sort

Add duration sorting options to YouTube playlists.

Fra 08.10.2020. Se den seneste versjonen.

// ==UserScript==
// @name         YouTube Playlist Duration Sort
// @namespace    http://tampermonkey.net/
// @version      0.3
// @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==

// VERSION HISTORY
// ---------------
// v0.2  8-Oct-2020 Initial public release.
// v0.3  8-Oct-2020 Removed unusued/unneeded functions. Reordered code for ease of reading. Wrote out use of wait().

(function() {
    /* USERSCRIPT CODE */
    '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 = "(" + injectedScript + ")();";

        document.body.appendChild(script);

        logMsg("Javascript injected!");
    }

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


    /* JAVASCRIPT TO INJECT INTO PAGE */
    function injectedScript() {
        'use strict';
        logMsg("Initialising injected javascript...");

        const debug = false;
        var duratonSortRunning = false;
        var currentDelay = 0;

        // Setup code.
        document.getElementById('durationSortClickShortest').onclick = durationSortClickShortest;
        document.getElementById('durationSortClickLongest').onclick = durationSortClickLongest;
        document.addEventListener('ytplDurationSortEvent', durationSortEvent);

        // EVENT HANDLERS.
        function durationSortClickShortest() {
            logMsg('durationSortClickShortest')
            delayedRun(function(){
                var event = new CustomEvent('ytplDurationSortEvent', {
                    detail: {
                        operation: "init", order: 0
                    }
                });
                document.dispatchEvent(event);
            }, 50);
        }

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

        function durationSortEvent(e) {
            logDebug("durationSortEvent("+JSON.stringify(e.detail)+")");
            currentDelay = 0;
            switch(e.detail.operation) {
                case "init":
                    logMsg("Initialising playlist sorting...");
                    clickMenu(e);
                    break;
                case "scroll-to-end":
                    logMsg("Ensuring playlist is fully loaded...");
                    loadPlaylist(e);
                    break;
                case "sort":
                    sortPlaylist(e);
                    break;
            }
        }

        // WORKER FUNCTIONS.
        function clickMenu(e) {
            // De-click the menu.
            delayedRun(function(){
                var elemMenu = document.querySelector("#sort-filter-menu > yt-sort-filter-sub-menu-renderer > yt-dropdown-menu > paper-menu-button");
                var evObj = document.createEvent('Events');
                evObj.initEvent('click', true, false);
                elemMenu.dispatchEvent(evObj);
            }, 50);

            // Then initiate the sort.
            e.detail.operation = "sort";
            delayedRun(function(){
                var event = new CustomEvent('ytplDurationSortEvent', {detail: e.detail});
                document.dispatchEvent(event);
            }, 100);
        }

        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.
                delayedRun(function(){
                    var event = new CustomEvent('ytplDurationSortEvent', {
                        detail: { operation: "sort", order: e.detail.order }
                    });
                    document.dispatchEvent(event);
                }, 250);
            }
            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;
            var eventSent=false;
            for (var i = 1; numInList > 1 && i < numInList && !eventSent; 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.
                        delayedRun(function(){
                            var event = new CustomEvent('ytplDurationSortEvent', {
                                detail: e.detail
                            });
                            document.dispatchEvent(event);
                        }, 750);
                        eventSent=true;
                    }
                } else {
                    logDebug(" - Ignoring row "+nInd+" (duration: "+nSecs+", prevDuration: "+nPrevSecs+")");
                }
            }

            if(!eventSent) {
                logMsg("Playlist sorting completed.");
            } else {
                logDebug(" - sortPlaylist("+JSON.stringify(e.detail)+") finished");
            }
        }

        // PLAYLIST FUNCTIONS.
        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+")");
        }


        // GENERIC UTILITY FUNCTIONS
        function delayedRun(code, delay=100) {
            currentDelay += delay;
            setTimeout(code, delay);
        }

        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);
        };

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

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

        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);
            fireMouseEvent('mouseenter', elemDrag, center1X, center1Y);
            fireMouseEvent('mouseover', elemDrag, center1X, center1Y);
            fireMouseEvent('mousedown', elemDrag, center1X, center1Y);

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

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

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

            return true;
        }

        logMsg("Initialisation of injected javascript finsihed.");
    }
})();