osu! Beatmap Downloaded Indicator

在 osu! 圖譜列表頁面中,暗化顯示本地已下載的圖譜。

// ==UserScript==
// @name               osu! Beatmap Downloaded Indicator
// @name:zh            osu! Beatmap Downloaded Indicator
// @name:zh-CN         osu! Beatmap Downloaded Indicator
// @name:zh-TW         osu! Beatmap Downloaded Indicator

// @namespace          https://github.com/karin0/osu-bdi
// @version            0.4

// @description        Dim the beatmaps that are already downloaded in the osu! beatmap listing.
// @description:zh-cn  在 osu! 谱面列表页面中,暗化显示本地已下载的谱面。
// @description:zh-tw  在 osu! 圖譜列表頁面中,暗化顯示本地已下載的圖譜。
// @license            MIT
// @author             karin0

// @icon               https://osu.ppy.sh/favicon.ico
// @match              http*://osu.ppy.sh/*
// @grant              none
// ==/UserScript==

(function () {
    const port_default = '35677', obdi_page = 'https://github.com/karin0/osu-bdi';

    const css = document.createElement('style');
    css.type = 'text/css';
    css.innerText = `
        .di-done {
            filter: brightness(80%) contrast(80%) opacity(20%);
        }
        .di-input {
            width: 6em;
            height: 2.2em;
            margin: auto;
            padding: 10px;
            background-color: hsl(var(--hsl-b2));
            border: 1px solid hsl(var(--hsl-b4));
            -moz-appearance: textfield;
        }
        .di-input::-webkit-inner-spin-button {
            -webkit-appearance: none;
            margin: 0;
        }
        .di-status {
            align-items: center;
            display: flex;
            color: #fff;
            margin: auto 0.6em;
        }
    `;

    const map = new Map();
    const set = new Set();

    function get_id(e) {
        if (!e.dataset.diid) {
            const a = e.getElementsByTagName('a')[0];
            if (!a)
                return undefined;
            const href = a.getAttribute('href');
            if (!href)
                return undefined;
            const id = Number(href.substring(href.lastIndexOf('/') + 1));
            if (!id)
                return undefined;
            map[e.dataset.diid = id] = e;
            return id;
        }
        return Number(e.dataset.diid);
    }

    function set_downloaded(e) {
        if (!e)
            return;
        e.classList.add('di-done')
        const i = e.querySelector('i.fa-download');
        if (i) {
            i.classList.remove('fa-download');
            i.classList.add('fa-check-circle');
        }
    }

    function set_undownloaded(e) {
        if (!e)
            return;
        e.classList.remove('di-done')
        const i = e.querySelector('i.fa-check-circle');
        if (i) {
            i.classList.remove('fa-check-circle');
            i.classList.add('fa-download');
        }
    }

    function add(id) {
        if (!set.has(id)) {
            set.add(id);
            set_downloaded(map[id]);
        }
    }

    function remove(id) {
        if (set.has(id)) {
            set.remove(id);
            set_undownloaded(map[id]);
        }
    }

    const port_input = document.createElement('input');
    port_input.type = 'number';
    port_input.min = 1;
    port_input.max = 65535;
    port_input.classList.add('di-input');
    port_input.placeholder = 'obdi Port'

    const stored_port = localStorage.getItem('di_port');
    port_input.value = stored_port ? stored_port : port_default.toString();

    const status = document.createElement('a');
    status.classList.add('di-status');
    status.href = obdi_page;
    status.target = '_blank';

    function on_message(e) {
        let removing = false;
        const data = e.data.split(' ');
        console.log('received', data.length, 'commands');
        for (const s of data) {
            const id = Number(s);
            if (id)
                (removing ? remove : add)(id);
            else if (s == '+')
                removing = false;
            else if (s == '-')
                removing = true;
            else {
                for (const id of set)
                    set_undownloaded(map[id]);
                set.clear();
            }
        }
    }

    function on_open() {
        status.innerText = 'obdi Connected';
    }

    let socket = null, tryer = 0, tryer_cnt = 0;

    function disconnect() {
        if (socket) {
            socket.onmessage = socket.onopen = socket.onclose = null;
            socket.close()
        }
    }

    function retry(id) {
        if (tryer != id)
            return;
        console.log(id, 'retrying', socket, socket ? socket.readyState : 'qwq');
        const state = socket ? socket.readyState : null;
        if (state == WebSocket.OPEN)
            return tryer = 0;
        if (state != WebSocket.CONNECTING) {
            disconnect();
            socket = new WebSocket('ws://127.0.0.1:' + port_input.value);
            socket.onmessage = on_message;
            socket.onopen = on_open;
            socket.onclose = connect;
        }
        setTimeout(() => retry(id), 1000);
    }

    function connect() {
        if (tryer)
            return;
        status.innerText = 'obdi Disconnected';
        tryer = ++tryer_cnt;
        console.log('connects', tryer);
        retry(tryer);
    }

    port_input.onchange = function () {
        console.log('change to', port_input.value, tryer);
        localStorage.setItem('di_port', port_input.value);
        if (tryer)
            tryer = 0;
        disconnect();
        const port = Number(port_input.value);
        if (0 < port && port < 65536)
            connect();
        else
            status.innerText = 'obdi Disconnected';
    };

    const observer = new MutationObserver(function (muts) {
        for (const mut of muts)
            for (const node of mut.addedNodes)
                for (const e of node.querySelectorAll('div.beatmapsets__item')) {
                    const id = get_id(e);
                    if (id && set.has(id))
                        set_downloaded(e);
                }
    });

    // Store global elements to make init_dom idempotent, as navi_observer can be invoked
    // multiple times when navigating.
    let topbar = null;
    function attach_topbar() {
        const n = document.querySelector('div.nav2__colgroup');
        if (topbar == n) {
            return;
        }
        if ((topbar = n)) {
            topbar.appendChild(port_input);
            topbar.appendChild(status);
        }
    }

    let root = null;
    function start_observe() {
        const n = document.querySelector('div.osu-layout__row');
        if (root == n) {
            return;
        }
        root = n;
        observer.disconnect();
        console.log('observing', root);
        if (root) {
            observer.observe(root, {
                childList: true, subtree: true
            });
        }
    }

    function init_dom() {
        attach_topbar();
        start_observe();
    }

    const navi_observer = new MutationObserver(init_dom);

    window.addEventListener('load', function () {
        document.head.appendChild(css);

        init_dom();

        // turbolinks does navigation by replacing the <body>, which invalidates the old observer.
        // Observe childList of <html> to detect this.
        navi_observer.observe(document.querySelector('html'), {
            childList: true
        });
        for (const e of document.querySelectorAll('div.beatmapsets__item'))
            get_id(e);

        connect();
    });
})();