YouTube Playlist Duration Sort

Add duration sorting options to YouTube playlists.

Ajankohdalta 20.10.2020. Katso uusin versio.

// ==UserScript==
// @name         YouTube Playlist Duration Sort
// @namespace    http://tampermonkey.net/
// @version      0.4
// @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        none
// @run-at       document-idle
// ==/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().
// v0.4  20-Oct-2020 Restructured to modern format. Made this a prviate script - it only works on Chrome on Windows.

'use strict';

logMsg("Initialising YouTube Playlist Duration Sort...");

const debug = true;

// Setup code.
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;
    var currentDelay = 0;

    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 YouTube Playlist Duration Sort finished...");
}

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