Greasy Fork is available in English.

Custom channel sort with favorites

Reorders the followed channels list in the sidebar based on viewcount or alphabetical order, allows to place favorites on the top of the list.

// ==UserScript==
// @name         Custom channel sort with favorites
// @namespace    https://github.com/tomasz13nocon
// @version      1.0
// @description  Reorders the followed channels list in the sidebar based on viewcount or alphabetical order, allows to place favorites on the top of the list.
// @author       Lloyd WESTBURY
// @match        https://www.twitch.tv/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @run-at document-start
// @homepageURL  https://github.com/LloydWes/Twitch-custom-sort-with-favorite
// @license      MIT
// ==/UserScript==

(async function () {

    'use strict';

    function findReact(dom) {
        if (dom[Object.keys(dom).find(a=>a.startsWith("__reactProps$"))].children) {
            return dom[Object.keys(dom).find(a=>a.startsWith("__reactProps$"))].children;
        }
        else {
            return dom[Object.keys(dom).find(a=>a.startsWith("__reactInternalInstance$"))].pendingProps.children;
        }
    }

    function waitForElement(querySelector) {
        return new Promise((resolve, reject) => {
            if (document.querySelectorAll(querySelector).length) resolve();
            const observer = new MutationObserver(() => {
                if (document.querySelectorAll(querySelector).length) {
                    observer.disconnect();
                    return resolve();
                }
            });
            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        });
    }

    let callback = null;
    function mettreAJourCallback() {
        if (GM_getValue("alpha")) { 
            callback = (a, b) => {
                let an = getNameWhatever(a);
                let bn = getNameWhatever(b);
                return ('' + an).localeCompare(bn);
            };
        } else {
            callback = (a, b) => {
                return viewcount(b) - viewcount(a);
            }
        }
    }

    async function ajouterBoutonEtLancerTrie() {
        let e;
        let followedSectionButton;
        let g;
        
        while(!(e = document.querySelector("[data-a-target='side-nav-header-expanded']"))) await wait(200);
        while(!(followedSectionButton = document.querySelector(".followed-side-nav-header--expanded"))) await wait(200);
        while(!(g = document.querySelector(".followed-side-nav-header__dropdown-trigger"))) await wait(200);
        let followedButtonCallBack = (event) => {
            GM_setValue("alpha", !GM_getValue("alpha"));
            g.firstElementChild.lastElementChild.innerHTML = GM_getValue("alpha") ? "Alpha" : "Spec";
            let svgHolder = g.lastElementChild.lastElementChild.lastElementChild.lastElementChild.lastElementChild;
            let path = document.createElement('path');
            if (GM_getValue("alpha")) {
                svgHolder.setAttribute('d', 'M14.94 4.66h-4.72l2.36-2.36 2.36 2.36zm-4.69 14.71h4.66l-2.33 2.33-2.33-2.33zM6.1 6.27L1.6 17.73h1.84l.92-2.45h5.11l.92 2.45h1.84L7.74 6.27H6.1zm-1.13 7.37l1.94-5.18 1.94 5.18H4.97zm10.76 2.5h6.12v1.59h-8.53v-1.29l5.92-8.56h-5.88v-1.6h8.3v1.26l-5.93 8.6z');
            } else {
                svgHolder.setAttribute('d', 'M11 6 7 2 3 6l1.5 1.5L6 6v6h2V6l1.5 1.5L11 6Zm6 8-4 4-4-4 1.5-1.5L12 14V8h2v6l1.5-1.5L17 14Z');
            }
            getStreamsAndSortThem();
            event.stopImmediatePropagation();
        }
        followedSectionButton.addEventListener('click', followedButtonCallBack);
        g.firstElementChild.lastElementChild.innerHTML = GM_getValue("alpha") ? "Alpha" : "Spec";
        let svgHolder = g.lastElementChild.lastElementChild.lastElementChild.lastElementChild.lastElementChild;
        if (GM_getValue("alpha")) {
            svgHolder.setAttribute('d', 'M14.94 4.66h-4.72l2.36-2.36 2.36 2.36zm-4.69 14.71h4.66l-2.33 2.33-2.33-2.33zM6.1 6.27L1.6 17.73h1.84l.92-2.45h5.11l.92 2.45h1.84L7.74 6.27H6.1zm-1.13 7.37l1.94-5.18 1.94 5.18H4.97zm10.76 2.5h6.12v1.59h-8.53v-1.29l5.92-8.56h-5.88v-1.6h8.3v1.26l-5.93 8.6z');
        } else {
            svgHolder.setAttribute('d', 'M11 6 7 2 3 6l1.5 1.5L6 6v6h2V6l1.5 1.5L11 6Zm6 8-4 4-4-4 1.5-1.5L12 14V8h2v6l1.5-1.5L17 14Z');
        }

    }
    let sidebar;
    let boutonAjouteEtTrie = false;

    while ((sidebar = document.getElementsByClassName("side-bar-contents")[0]) === undefined) {
        await new Promise(r => setTimeout(r, 500));
    }




    let weMutatedDom = false;
    let mutationSideBar = new MutationObserver((mutations) => {
        let fo = document.querySelectorAll(".side-nav-section .tw-transition-group")
        if (fo.length) {
            let bar = document.createElement("div");
            bar.classList.add("truc");
            weMutatedDom = true;
            let div1 = document.createElement("div");
            let div2 = document.createElement("div");
            let div3 = document.createElement("div");
            div3.classList.add("side-nav-card")
            div1.classList.add("mbar");
            div1.style.cssText = "transition-property: transform, opacity; transition-timing-function: ease; border-bottom: 3px solid #bf94ff;"
            div1.appendChild(div2);
            div2.appendChild(div3);

            fo[0].appendChild(div1);

            mutationSideBar.disconnect();
        }
    });
    mutationSideBar.observe(sidebar, {attributes: false, childList: true, subtree: true, characterData: true });
    let getStreamsAndSortThem = (mutations) => {
        mettreAJourCallback();
        if (weMutatedDom) {
            weMutatedDom = false;
            return;
        }

        // We're only interested in "new nodes added" and "text changed" mutations.
        let relevantMutation = false;

        if (mutations && mutations.length) {
            mutations.forEach((mutation) => {
                if (mutation.addedNodes[0] || mutation.type === "characterData") {
                    relevantMutation = true;
                }
            });
        } else {
            relevantMutation = true;
        }
        if (!relevantMutation) return;

        let followedSection = sidebar.getElementsByClassName("side-nav-section")[0];

        // Mapping to 2 parents up, as that's the outermost element for a single channel
        let streams = [...followedSection.querySelectorAll(".side-nav-section div:not(:first-child) div.side-nav-card")].map(el => el.parentNode.parentNode);

            streams.sort((a, b) => {
                let aIsBar = isBar(a);
                let bIsBar = isBar(b);
                if (aIsBar) {
                    let bOnline = isOnline(b);
                    if (bOnline && isFav(b)) {
                        return 1;
                    } else {
                        return -1;
                    }
                } else if (bIsBar) {
                    let aOnline = isOnline(a);
                    if (aOnline && isFav(a)) {
                        return -1;
                    } else {
                        return 1;
                    }
                }
                let aOnline = isOnline(a);
                let bOnline = isOnline(b);
                if (aOnline && bOnline) {
                    let aIsFav = isFav(a);
                    let bIsFav = isFav(b);
                    if (aIsFav && bIsFav) {
                        return callback(a,b);
                    } else if (aIsFav) {
                        return -1;
                    } else if (bIsFav) {
                        return 1;
                    } else {
                        return callback(a,b);
                    }
                } else if (aOnline || bOnline) {
                    let aIsFav = isFav(a);
                    let bIsFav = isFav(b);
                    if (aOnline) {
                        return -1;
                    } else if (bOnline) {
                        return 1;
                    } else if (aIsFav) {
                            return -1;
                    } else if (bIsFav) {
                            return 1;
                    } else {
                        return 0;
                    }
                }
            });


        weMutatedDom = true;
        streams[0].parentNode.append(...streams);
        if (!boutonAjouteEtTrie) {
            ajouterBoutonEtLancerTrie();
            boutonAjouteEtTrie = true;
        }
    }

    function isBar(el) {
        return el.classList.contains("mbar")
    }
    function isFav(a) {
        return GM_getValue(getNameWhatever(a).toLowerCase(), undefined) ? 1 : 0;
    }

    function getNameWhatever(element) {
        let component = findReact(element);
        // If "stream" property doesn't exist (optional chaining below) then the stream is offline.
        if (component.props.stream) {
            return component
                .props.stream
                .user.login;
        } else {
            return component
                .props.videoConnection
                .user.login;
        }
    }

    function isOnline(element) {
        let component = findReact(element);
        // If "stream" property doesn't exist (optional chaining below) then the stream is offline.
        if (component.props.stream) {
            return 1
        } else {
            return 0;
        }
    }

    function viewcount(element) {
        let component = findReact(element);
        // If "stream" property doesn't exist (optional chaining below) then the stream is offline.
        if (component.props.stream) {
            return component
                .props.stream
                .content.viewersCount;
        } else {
            return null;
        }
    }

    new MutationObserver(getStreamsAndSortThem).observe(sidebar, {attributes: false, childList: true, subtree: true, characterData: true });


    let clickedEl = null;
    document.addEventListener("contextmenu", function(event){
        clickedEl = event.target;
    });

    GM_registerMenuCommand("toggle favorite", () => {
        if(clickedEl) {
            let e = clickedEl;
            let limit = 10;
            while (!e.classList.contains("side-nav-card")) {
                e = e.parentNode;
                limit--;
                if (limit <= 0) break;
            }
            let n = getNameWhatever(e.parentNode.parentNode);
            let currentValue = GM_getValue(n)
            if (currentValue) {
                GM_deleteValue(n);
            }
            else {
                GM_setValue(n, 1);
            }
            getStreamsAndSortThem();
            clickedEl = null;
        }
    });
    const wait = async (ms) => { await new Promise((resolve) => { setTimeout(resolve, ms); });}
}());