mai-notes++

1/26/2026, 8:58:23 PM

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        mai-notes++
// @namespace   Violentmonkey Scripts
// @match       https://mai-notes.com/player.html*
// @version     1.0
// @author      Dylan Dang
// @description 1/26/2026, 8:58:23 PM
// @require     https://unpkg.com/lighterhtml
// @require     https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4
// @grant       GM_setValue
// @grant       GM_getValue
// @run-at      document-start
// @license MIT
// ==/UserScript==

function afterHook(target, methodName, after) {
    const originalMethod = target[methodName];
    target[methodName] = function (...args) {
        const returnValue = originalMethod.apply(this, args);
        after.apply(this, [args, returnValue]);
        return returnValue;
    }
}

const scriptTagObserver = new MutationObserver((mutations) => {
    mutations.forEach(mutation => {
        mutation.addedNodes.forEach(async node => {
            if (node.tagName === "SCRIPT") {
                if (node.src.includes("/assets/player")) {
                    // Remove the module tag to make variables global
                    node.type = "";
                }
            }
        });
    });
});
scriptTagObserver.observe(document.documentElement, { childList: true, subtree: true });


async function ready() {
    return new Promise((resolve) => {
        let interval;
        function check() {
            if (!Wi || !Gi) return false;
            resolve({ player: Wi, audioManager: Gi });
            clearInterval(interval);
            return true;
        }
        if (check()) return;
        interval = setInterval(check, 30);
    });
}

const { html: { node: html } } = lighterhtml;

async function loadMaterialSymbolsFont() {
    return new Promise((resolve) => {
        document.head.appendChild(html`
        <link rel="stylesheet"
              onload=${resolve} 
              href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />
        `);
    });
}

/**
 * Wait for an element to appear in the DOM.
 * @param {string} selector - The CSS selector to watch for.
 * @param {Node} parent - The container to watch (defaults to document).
 */
async function getElement(selector, parent = document) {
    return new Promise((resolve) => {
        const existingElement = parent.querySelector(selector);
        if (existingElement) {
            return resolve(existingElement);
        }
        const observer = new MutationObserver((mutations) => {
            const target = parent.querySelector(selector);
            if (target) {
                resolve(target);
                observer.disconnect();
            }
        });
        observer.observe(parent === document ? document.documentElement : parent, {
            childList: true,
            subtree: true,
        });
    });
}

function handleFullscreenChange() {
    const canvas = document.getElementById("chartCanvas");
    if (document.fullscreenElement === canvas) {
        canvas.parentElement.style.maxWidth = "100%";
    } else {
        canvas.parentElement.removeAttribute("style");
    }
    window.dispatchEvent(new Event('resize'));
}

const fullScreenChangeEvents = [
    'fullscreenchange',
    'webkitfullscreenchange',
    'mozfullscreenchange',
    'MSFullscreenChange'
];

fullScreenChangeEvents.forEach(eventType => {
    document.addEventListener(eventType, handleFullscreenChange);
});

function fullscreen() {
    const canvas = document.getElementById("chartCanvas");
    canvas.parentElement.style.maxWidth = "100%";
    if (canvas.requestFullScreen) {
        canvas.requestFullScreen();
    } else if (canvas.webkitRequestFullScreen) {
        canvas.webkitRequestFullScreen();
    } else if (canvas.mozRequestFullScreen) {
        canvas.mozRequestFullScreen();
    }
}
document.addEventListener('keydown', function (e) {
    const active = document.activeElement;
    const notEditing = !(active && (
        active.tagName === 'INPUT' ||
        active.tagName === 'TEXTAREA' ||
        active.isContentEditable
    ));
    if (e.code === 'Space' && notEditing) {
        e.preventDefault();
        document.getElementById('playPauseButton')?.click();
    }

    if ((e.code === 'ArrowRight' || e.key === 'ArrowRight') && notEditing) {
        if (e.shiftKey) {
            e.preventDefault();
            document.getElementById('measure5ForwardButton')?.click();
        } else if (e.ctrlKey) {
            e.preventDefault();
            document.getElementById('measureForwardButton')?.click();
        } else {
            e.preventDefault();
            document.getElementById('positionForwardButton')?.click();
        }
    }

    if ((e.code === 'ArrowLeft' || e.key === 'ArrowLeft') && notEditing) {
        if (e.shiftKey) {
            e.preventDefault();
            document.getElementById('measure5BackButton')?.click();
        } else if (e.ctrlKey) {
            e.preventDefault();
            document.getElementById('measureBackButton')?.click();
        } else {
            e.preventDefault();
            document.getElementById('positionBackButton')?.click();
        }
    }

    if ((e.code === 'KeyF' || e.key === 'f' || e.key === 'F') && notEditing) {
        e.preventDefault();
        if (
            document.fullscreenElement ||
            document.webkitFullscreenElement ||
            document.mozFullScreenElement
        ) {
            if (document.exitFullscreen) {
                document.exitFullscreen();
            } else if (document.webkitExitFullscreen) {
                document.webkitExitFullscreen();
            } else if (document.mozCancelFullScreen) {
                document.mozCancelFullScreen();
            }
        } else {
            fullscreen();
        }
    }

    if ((e.code === 'KeyR' || e.key === 'r' || e.key === 'R') && notEditing) {
        e.preventDefault();
        document.getElementById('restartButton')?.click();
    }
});

document.addEventListener("wheel", function (e) {
    if (
        document.fullscreenElement ||
        document.webkitFullscreenElement ||
        document.mozFullScreenElement
    ) {
        if (e.deltaY < 0) {
            if (e.shiftKey) {
                document.getElementById('measure5BackButton')?.click();
            } else if (e.ctrlKey) {
                document.getElementById('measureBackButton')?.click();
            } else {
                document.getElementById('positionBackButton')?.click();
            }
        } else if (e.deltaY > 0) {
            if (e.shiftKey) {
                document.getElementById('measure5ForwardButton')?.click();
            } else if (e.ctrlKey) {
                document.getElementById('measureForwardButton')?.click();
            } else {
                document.getElementById('positionForwardButton')?.click();
            }
        }
    }
}, { passive: true });

async function reInitializeAudioManager() {
    const { audioManager } = await ready();
    audioManager.audioContext = new (
        window.AudioContext || window.webkitAudioContext
    )();
    const response = await fetch("https://sdez.zip/voices/system/jp/circle/fx/SE_GAME_ANSWER.mp3");
    const arrayBuffer = await response.arrayBuffer();
    audioManager.answerBuffer = await audioManager.audioContext.decodeAudioData(arrayBuffer);
    audioManager.initialized = true;
}

async function injectAudioUploadUI() {
    const preview = html`<audio class="w-full mt-3 rounded" controls style="display:none"
    onseeking=${() => {
            if (!Wi) return;
            const trueTime = Wi.Gt(Wi.timeline.te()) / 1000;
            if (Wi.playing && Math.abs(preview.currentTime - trueTime) > 0.01) {
                preview.currentTime = trueTime;
            }
        }}/>`;

    const info = html`<div class="mt-3 text-gray-500 text-sm" />`;


    function handleFile(file) {
        if (!file.type.startsWith('audio/')) {
            info.textContent = 'Please select an audio file.';
            preview.style.display = 'none';
            preview.src = '';
            return;
        }
        info.textContent = `Selected: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
        const url = URL.createObjectURL(file);
        preview.src = url;
        preview.style.display = '';
    }

    let dragCounter = 0;
    (await getElement(".controls-section")).after(html`
        <h2 style="font-size: 1.3rem;">Sync Local Audio</h2>
        <div 
            id="mai-notes-upload-area"
            class="flex flex-col items-center justify-center border-2 border-dashed border-gray-400 rounded-lg !p-8 !mt-4 bg-opacity-80 shadow transition-colors duration-200 hover:border-blue-500 select-none cursor-pointer transition-background-color"
            style="min-width:280px; max-width:800px; margin:auto;"
            onclick=${e => {
            const input = e.currentTarget.querySelector('input[type="file"]');
            if (e.target !== input) input.click();
        }}
            ondragenter=${e => {
            e.preventDefault();
            dragCounter++;
            e.currentTarget.classList.add('bg-blue-100');
        }}
            ondragover=${e => {
            e.preventDefault();
            e.currentTarget.classList.add('bg-blue-100');
        }}
            ondragleave=${e => {
            e.preventDefault();
            dragCounter--;
            if (dragCounter <= 0) {
                e.currentTarget.classList.remove('bg-blue-100');
                dragCounter = 0;
            }
        }}
            ondrop=${e => {
            e.preventDefault();
            dragCounter = 0;
            e.currentTarget.classList.remove('bg-blue-100');
            const files = e.dataTransfer.files;
            if (files.length > 0) handleFile(files[0]);
        }}
        >
            <div style="width:100%" class="flex flex-col items-center text-center">
                <span class="material-symbols-outlined text-5xl text-blue-400 mb-2">upload_file</span>
                <span class="font-semibold text-gray-700 mb-2">Drag & drop your audio file or <span class="text-blue-500">browse file</span></span>
                
                <input 
                    type="file" 
                    id="audio-upload-input" 
                    accept="audio/*"
                    class="hidden"
                    onchange=${e => {
            const files = e.target.files;
            if (files.length > 0) handleFile(files[0]);
        }}
                >
            </div>
            ${info}
            ${preview}
        </div>
    `);

    return preview;
}


async function loadSettings() {
    const { audioManager } = await ready();
    const answerSoundEnabledElement = document.getElementById("answerSoundEnabled");
    answerSoundEnabledElement.addEventListener("change", (e) => {
        e.preventDefault();
        GM_setValue("answerSoundEnabled", e.target.checked);
    });
    const answerSoundEnabled = GM_getValue("answerSoundEnabled", true);
    answerSoundEnabledElement.checked = answerSoundEnabled;
    audioManager.be(answerSoundEnabled);
}

async function main() {
    await loadMaterialSymbolsFont();
    const playButton = await getElement("#playPauseButton");
    playButton.after(html`<button id="restartButton" class="control-button material-symbols-outlined !leading-normal" onclick=${fullscreen}>crop_free</button>`);

    await loadSettings();
    const audio = await injectAudioUploadUI();
    await reInitializeAudioManager();
    const { player } = await ready();

    document.querySelector("#playbackSpeedSlider").addEventListener("input", (e) => {
        audio.playbackRate = e.target.value;
    });

    function setAudioCurrentTime() {
        audio.currentTime = Wi.playbackStartPositionMs / 1000;
    }

    const playerPrototype = Object.getPrototypeOf(player);

    afterHook(playerPrototype, "re", () => {
        setAudioCurrentTime();
        audio.play();
    });
    afterHook(playerPrototype, "oe", setAudioCurrentTime);


    afterHook(playerPrototype, "ae", () => {
        audio.pause();
    });

}

main();