Internet Roadtrip Turn Alert

Play sound when turn options appear after a long stretch of straight road.

// ==UserScript==
// @name        Internet Roadtrip Turn Alert
// @namespace   jdranczewski.github.io
// @match       https://neal.fun/internet-roadtrip/*
// @version     0.2.1
// @author      jdranczewski
// @description Play sound when turn options appear after a long stretch of straight road.
// @license     MIT
// @icon         https://files.catbox.moe/fdkl61.png
// @grant        GM.setValues
// @grant        GM.getValues
// @grant        GM.addStyle
// @grant        GM.notification
// @grant        unsafeWindow
// @run-at      document-end
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==

// This works together with irf.d.ts to give us type hints
/**
 * Internet Roadtrip Framework
 * @typedef {typeof import('internet-roadtrip-framework')} IRF
 */

(async function() {
    // Styles
    GM.addStyle(`
    #ta-alert-box {
        position: fixed;
        pointer-events: none;
        width: 100%;
        height: 100%;
        background-color: #00000036;
        z-index: 1000000;
        transition: opacity 3s;
        opacity: 0;
        background-image: url("https://files.catbox.moe/l0mcvt.png");
        background-repeat: no-repeat;
        background-position: center;
        display: flex;
        justify-content: center;
        align-items: center;
    }
    
    #ta-alert-box span {
        color: white;
        font-weight: bold;
        text-shadow: 1px 1px 2px #000000;
        font-size: 23px;
        transform: translate(0px, 50px);
    }

    #ta-alert-box.ta-alert-show {
        opacity: 1 !important;
        transition: opacity .5s;
    }
    `)
    // References
    const v_container = await IRF.vdom.container;
    const v_map = await IRF.vdom.map;
    
    // Settings
    const settings = {
        "turn_alert_sound": true,
        "turn_alert_visual": true,
        "turn_alert_notif": false,
        "minutes": 5,
        "sound": 'https://files.catbox.moe/04idsc.mp3',
        "volume": 0.3,

        "marker_alert_sound": true,
        "marker_alert_visual": true,
        "marker_alert_notif": false,
        "distance": 250,
        "marker_sound": 'https://files.catbox.moe/83p4v5.mp3',
        "marker_volume": 0.3,
    }
    const storedSettings = await GM.getValues(Object.keys(settings))
    Object.assign(
        settings,
        storedSettings
    );
    if (settings.sound == 'https://files.catbox.moe/6beir6.mp3') {
        // Replace the default sound with a better version
        settings.sound = "https://files.catbox.moe/04idsc.mp3";
    }
    await GM.setValues(settings);

    // Visual alert setup
    const alert_box = document.createElement("div");
    alert_box.id = "ta-alert-box";
    document.body.appendChild(alert_box);
    function warn_visual(text="") {
        alert_text.innerText = text;
        alert_box.classList.toggle("ta-alert-show", true);
        setTimeout(() => {
            alert_box.classList.toggle("ta-alert-show", false);
        }, 2500);
    }
    const alert_text = document.createElement("span");
    alert_box.appendChild(alert_text)

    // Settings panel GUI
    let gm_info = GM.info
    gm_info.script.name = "Turn alert"
    const irf_settings = IRF.ui.panel.createTabFor(
        gm_info, {
            tabName: "Turn alert",
            style: `
            .ta-straight-n {font-weigth: bold}
            .ta-bad {color: #ff3434}
            .ta-good {color: #0f0 !important}
            `
        }
    );

    // Set up and status
    let straight_streak = 0;
    const status = {};
    {
        const status_el = document.createElement("div");
        status_el.innerText = "Status:"
        const status_ul = document.createElement("ul");
        status_el.appendChild(status_ul)
        // Stop numbers
        let li = document.createElement("li");
        status.straight_n = document.createElement("span");
        status.straight_n.style.fontWeight = "bold";
        li.appendChild(status.straight_n);
        li.append("/");
        status.straight_lim = document.createElement("span");
        status.straight_lim.style.color = "#aaa";
        li.appendChild(status.straight_lim);
        li.append(" stops going straight.")
        status_ul.appendChild(li);
        // Next stop status
        li = document.createElement("li");
        status.alert_next = document.createElement("span");
        status.alert_next.classList.add("ta-bad");
        li.appendChild(status.alert_next);
        li.append(" next turn - ");
        const force_button = document.createElement("button");
        force_button.innerText = "Force alert next turn";
        force_button.onclick = (e) => {
            straight_streak = 10000;
        }
        li.appendChild(force_button)
        status_ul.appendChild(li);
        // Connection to Tricks
        li = document.createElement("li");
        status.mmt = document.createElement("span");
        status.mmt.classList.add("ta-bad");
        li.appendChild(status.mmt);
        li.append(" to Minimap Tricks for markers.");
        status_ul.appendChild(li);

        irf_settings.container.appendChild(status_el);
    }

    // GUI objects
    function add_checkbox(
        name, identifier, callback=undefined,
        settings_container=irf_settings.container
    ) {
        let label = document.createElement("label");

        let checkbox = document.createElement("input");
        checkbox.type = "checkbox";
        checkbox.checked = settings[identifier];
        checkbox.className = IRF.ui.panel.styles.toggle;
        label.appendChild(checkbox);

        let text = document.createElement("span");
        text.innerText = " " + name;
        label.appendChild(text);

        checkbox.onchange = () => {
            settings[identifier] = checkbox.checked;
            GM.setValues(settings);
            if (callback) callback(checkbox.checked);
        }

        settings_container.appendChild(label);
        settings_container.appendChild(document.createElement("br"));
        settings_container.appendChild(document.createElement("br"));
    }

    function add_slider(
        name, identifier, callback=undefined,
        slider_bits=[1, 17, .5],
        settings_container=irf_settings.container
    ) {
        let label = document.createElement("label");

        let text = document.createElement("span");
        text.innerText = " " + name + ": ";
        label.appendChild(text);

        let value_label = document.createElement("span");
        value_label.innerText = settings[identifier];
        label.appendChild(value_label);

        let slider = document.createElement("input");
        slider.type = "range";
        slider.min = slider_bits[0];
        slider.max = slider_bits[1];
        slider.step = slider_bits[2];
        slider.value = settings[identifier];
        slider.className = IRF.ui.panel.styles.slider;
        label.appendChild(slider);

        slider.oninput = () => {
            settings[identifier] = slider.value;
            value_label.innerText = slider.value;
            GM.setValues(settings);
            if (callback) callback(slider.value);
        }
        slider.onmousedown = (e) => {e.stopPropagation()}

        settings_container.appendChild(label);
        settings_container.appendChild(document.createElement("br"));
        settings_container.appendChild(document.createElement("br"));
    }

    // Set up warn objects
    const howler = await IRF.modules.howler;
    class Warn {
        constructor(kind, settings_text, warn_text) {
            this._kind = kind;
            this._settings_text = settings_text;
            this._warn_text = warn_text;

            this.howl = new howler.Howl({
                src: [
                    settings[kind == "turn" ? "sound" : `${kind}_sound`]
                ],
                volume: settings[kind == "turn" ? "volume" : `${kind}_volume`]
            })

            this.settings = document.createElement("div");
            this.settings.appendChild(document.createElement("hr"));
            const heading = document.createElement("h3");
            heading.innerText = `${settings_text} warning`
            this.settings.appendChild(heading);
            add_checkbox(
                `${settings_text} visual warning`,
                `${kind}_alert_visual`, undefined,
                this.settings
            )
            add_checkbox(
                `${settings_text} desktop notification`,
                `${kind}_alert_notif`, undefined,
                this.settings
            )
            add_checkbox(
                `${settings_text} sound warning`,
                `${kind}_alert_sound`, undefined,
                this.settings
            )
            add_slider("Volume", (kind == "turn" ? "volume" : `${kind}_volume`),
            (value) => {
                this.howl.volume(value);
            }, [0, 1, 0.05], this.settings);

            // Set sound text box
            let label = document.createElement("label");

            let text = document.createElement("span");
            text.innerHTML = " Sound file URL (host on <a href='https://catbox.moe' target='_blank'>catbox.moe</a>):";
            label.appendChild(text);
            label.appendChild(document.createElement("br"));

            let box = document.createElement("input");
            box.value = settings[kind == "turn" ? "sound" : `${kind}_sound`];
            box.style.width = "100%";
            label.appendChild(box);

            this.settings.appendChild(label);
            this.settings.appendChild(document.createElement("br"));
            this.settings.appendChild(document.createElement("br"));

            // Test and save button
            let button = document.createElement("button");
            button.innerText = `Test ${kind} alert and save sound (if you hear it, it worked!)`;
            button.onclick = async () => {
                this.howl = new howler.Howl({
                    src: [
                        box.value
                    ],
                    volume: settings[kind == "turn" ? "volume" : `${kind}_volume`]
                })
                this.howl.once("end", () => {
                    settings[kind == "turn" ? "sound" : `${kind}_sound`] = box.value;
                    GM.setValues(settings);
                })
                this.warn();
            }
            this.settings.appendChild(button);
            this.settings.appendChild(document.createElement("br"));
            this.settings.appendChild(document.createElement("br"));
        }

        warn() {
            if (settings[`${this._kind}_alert_visual`]) {
                warn_visual(this._warn_text)
            }
            if (settings[`${this._kind}_alert_sound`]) {
                this.howl.play();
            }
            if (settings[`${this._kind}_alert_notif`]) {
                GM.notification(
                    `Warning! - ${this._warn_text}`,
                    "Internet Roadtrip",
                    "https://files.catbox.moe/fdkl61.png",
                    (e) => {console.log("notif click", e)}
                );
            }
        }
    }
    const warn_turn = new Warn("turn", "Turn", "Turn now!");
    add_slider(
        "Time going straight before alerting (minutes, approx.)",
        "minutes", undefined, [1, 60, 1], warn_turn.settings
    );
    const warn_marker = new Warn("marker", "Marker", "Marker ahead!");
    add_slider(
        "Distance from marker (meters, we move ~3m per second)",
        "distance", undefined, [50, 1500, 50], warn_marker.settings
    );
    irf_settings.container.appendChild(warn_turn.settings);
    irf_settings.container.appendChild(warn_marker.settings);

    // Override the setter for the number of available options
    // To warn if we encounter a turn suddenly
    // Get the original setter
    const { set: currentOptionsSetter } = Object.getOwnPropertyDescriptor(v_container.state, 'currentOptions');
    // Override the setter
    Object.defineProperty(v_container.state, 'currentOptions', {
        set(currentOptions) {
            // Set the units on the scale bar
            let straight_lim = settings.minutes*12;
            if (currentOptions.length == 1) {
                straight_streak += 1;
            } else {
                // console.log("Not straight!");
                if (straight_streak > straight_lim) {
                    warn_turn.warn();
                }
                straight_streak = 0;
            }
            status.straight_n.innerText = straight_streak;
            status.straight_lim.innerText = straight_lim;
            if (straight_streak > straight_lim) {
                status.alert_next.innerText = "Will alert";
                status.alert_next.classList.toggle("ta-good", true);
            } else {
                status.alert_next.innerText = "No alert";
                status.alert_next.classList.toggle("ta-good", false);
            }
            return currentOptionsSetter.call(this, currentOptions);
        },
        configurable: true,
        enumerable: true,
    });

    // Override changeStop to alert when we come close to a Marker
    const changeStop = v_container.methods.changeStop;
    const alerted = [];
    v_container.state.changeStop = new Proxy(changeStop, {
		apply: (target, thisArg, args) => {
			const returnValue = Reflect.apply(target, thisArg, args);
            const coords = args[5][0];
            if (unsafeWindow._MMT_getMarkers) {
                status.mmt.innerText = "Connected";
                status.mmt.classList.toggle("ta-good", true);
                const markers = unsafeWindow._MMT_getMarkers();
                if (markers) {
                    for (const [marker_id, marker] of Object.entries(markers)) {
                        let distance = marker.getLngLat().distanceTo(v_map.data.marker.getLngLat());
                        if (alerted.includes(marker_id)) {
                            if (distance > settings.distance) {
                                // Remove marker from alerted list if it's now out of range
                                const index = alerted.indexOf(marker_id);
                                if (index > -1) {
                                    alerted.splice(index, 1);
                                }
                            }
                        } else if (distance < settings.distance) {
                            alerted.push(marker_id);
                            warn_marker.warn();
                        }
                    }
                }
            } else {
                status.mmt.innerText = "Not connected";
                status.mmt.classList.toggle("ta-good", false);
            }
            return returnValue;
		},
	});

})();