ProMods Download Queue

Queues multipart ProMods downloads one at a time with a visible cooldown timer. Does not bypass the site's download limits.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ProMods Download Queue
// @name:ko      ProMods 분할 파일 다운로드 큐
// @namespace    https://github.com/DexfromAntarctica
// @homepageURL  https://github.com/DexfromAntarctica/promods-download-queue
// @supportURL   https://github.com/DexfromAntarctica/promods-download-queue/issues
// @version      0.3.1
// @description  Queues multipart ProMods downloads one at a time with a visible cooldown timer. Does not bypass the site's download limits.
// @description:ko ProMods 분할 파일을 쿨타임에 맞춰 하나씩 예약 다운로드합니다. 사이트의 다운로드 제한을 우회하지 않습니다.
// @license      MIT
// @match        https://promods.net/*
// @match        https://www.promods.net/*
// @compatible   chrome Tested with Tampermonkey
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_openInTab
// @noframes
// ==/UserScript==

(() => {
    'use strict';

    const JOB_KEY = 'promodsDownloadQueueJobV2';
    const LEGACY_JOB_KEY = 'promodsEuropeDownloadQueueJob';
    const COOLDOWN_KEY = 'promodsDownloadCooldownUntil';
    const LANGUAGE_KEY = 'promodsDownloadQueueLanguage';
    const INTERVAL_MS = 5 * 60 * 1000 + 20 * 1000;
    const DOWNLOAD_TAB_LIFETIME_MS = 30 * 1000;
    const OWNER_STALE_MS = 15 * 1000;
    // 페이지 인스턴스마다 새 ID를 써서 새 탭이 sessionStorage를 복제해도
    // 두 탭이 같은 큐를 동시에 실행하지 않게 합니다.
    const INSTANCE_ID = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;

    const PACKAGES = [
        { id: 'tgs', label: 'The Great Steppe', pattern: /promods-tgs-v/i },
        { id: 'maghreb', label: 'Maghreb', pattern: /promods-ma-v/i },
        { id: 'europe', label: 'Europe', pattern: /promods-(?:eu-)?v/i }
    ];

    const TEXT = {
        en: {
            genericTitle: 'ProMods Download Queue',
            packageTitle: '{package} Download Queue',
            close: 'Close',
            language: 'Language',
            fromPart: 'From part',
            toPart: 'To part',
            cancel: 'Cancel Queue',
            start: 'Start',
            launcher: 'Download Queue',
            counterRemaining: 'Time until the next download',
            counterReady: 'Download available',
            setupRequired: 'Open a multipart download list page to configure a queue.',
            singleFile: 'Only one file was detected; no download queue is needed.',
            detected: '{package} · {count} files detected · Ready',
            allSent: 'All selected download requests were sent.',
            waiting: '{package} · {part} waiting · {time}',
            preparing: '{package} · Preparing {part}…',
            rangeComplete: '{package} {start}–{end} requests complete',
            failed: '{part} failed: {error}',
            multipartRequired: 'Run this on a download list containing at least two multipart files.',
            invalidRange: 'Enter a valid start and end number.',
            missingLinks: 'Missing links: {parts}',
            canceled: 'Download queue canceled.'
        },
        ko: {
            genericTitle: 'ProMods 자동 다운로드',
            packageTitle: '{package} 자동 다운로드',
            close: '닫기',
            language: '언어',
            fromPart: '몇 번부터',
            toPart: '몇 번까지',
            cancel: '예약 취소',
            start: '실행',
            launcher: '다운로드 예약',
            counterRemaining: '다음 다운로드까지 남은 시간',
            counterReady: '다운로드 가능',
            setupRequired: '분할 파일 다운로드 목록 페이지에서 설정할 수 있습니다.',
            singleFile: '파일이 하나뿐이라 다운로드 예약이 필요하지 않습니다.',
            detected: '{package} · {count}개 감지됨 · 실행 대기 중',
            allSent: '선택한 파일의 다운로드 요청을 모두 보냈습니다.',
            waiting: '{package} · {part} 대기 · {time}',
            preparing: '{package} · {part} 다운로드 준비 중…',
            rangeComplete: '{package} {start}–{end} 요청 완료',
            failed: '{part} 실행 실패: {error}',
            multipartRequired: '분할 파일이 두 개 이상인 다운로드 목록에서 실행하세요.',
            invalidRange: '시작·끝 번호를 올바르게 입력하세요.',
            missingLinks: '링크 없음: {parts}',
            canceled: '다운로드 예약을 취소했습니다.'
        }
    };

    let timerId = null;
    let uiIntervalId = null;
    let downloadInProgress = false;

    function getLanguage() {
        return GM_getValue(LANGUAGE_KEY, 'en') === 'ko' ? 'ko' : 'en';
    }

    function setLanguage(language) {
        GM_setValue(LANGUAGE_KEY, language === 'ko' ? 'ko' : 'en');
    }

    function t(key, replacements = {}) {
        let value = TEXT[getLanguage()][key] || TEXT.en[key] || key;
        for (const [name, replacement] of Object.entries(replacements)) {
            value = value.replaceAll(`{${name}}`, String(replacement));
        }
        return value;
    }

    function readJob() {
        return GM_getValue(JOB_KEY, null);
    }

    function writeJob(job) {
        GM_setValue(JOB_KEY, job);
    }

    function clearJob() {
        GM_deleteValue(JOB_KEY);
    }

    function readCooldownUntil() {
        return Number(GM_getValue(COOLDOWN_KEY, 0)) || 0;
    }

    function startCooldown(startedAt = Date.now()) {
        const cooldownUntil = startedAt + INTERVAL_MS;
        GM_setValue(COOLDOWN_KEY, cooldownUntil);
        renderDockCounter();
        return cooldownUntil;
    }

    function parsePartNumber(text) {
        const match = String(text || '').match(/\.7z\.(\d{3})(?:\b|$)/i);
        return match ? Number(match[1]) : null;
    }

    function findPackageDefinition(text) {
        return PACKAGES.find(item => item.pattern.test(String(text || ''))) || null;
    }

    function findFileRow(link) {
        let element = link.parentElement;
        while (element && element !== document.body) {
            const filenames = element.querySelectorAll('.pm-file-name');
            if (filenames.length === 1) return element;
            if (filenames.length > 1) break;
            element = element.parentElement;
        }
        return null;
    }

    function detectCurrentPackage() {
        const files = new Map();
        const links = document.querySelectorAll('a.pm-dl-btn.free[href*="getdl.php"]');
        let packageDefinition = null;

        for (const link of links) {
            const href = link.href;
            const row = findFileRow(link);
            const displayedFilename = row?.querySelector('.pm-file-name')?.textContent?.trim() || '';
            const rowText = row?.textContent || '';
            let urlFilename = '';

            try {
                urlFilename = new URL(href, location.href).searchParams.get('dlfile') || '';
            } catch (_) {
                // URL이 예상과 달라도 화면에 표시된 파일명으로 인식합니다.
            }

            packageDefinition ||= findPackageDefinition(displayedFilename || urlFilename || rowText);
            const part = parsePartNumber(displayedFilename)
                ?? parsePartNumber(urlFilename)
                ?? parsePartNumber(rowText);

            if (part !== null) {
                files.set(part, {
                    part,
                    href,
                    filename: displayedFilename || urlFilename || `part-${part}`
                });
            }
        }

        if (!files.size) return null;

        const sortedFiles = new Map([...files.entries()].sort((a, b) => a[0] - b[0]));
        return {
            id: packageDefinition?.id || 'unknown',
            label: packageDefinition?.label || 'ProMods Package',
            files: sortedFiles
        };
    }

    function formatPart(part) {
        return String(part).padStart(3, '0');
    }

    function formatRemaining(milliseconds) {
        const seconds = Math.max(0, Math.ceil(milliseconds / 1000));
        const minutesPart = Math.floor(seconds / 60);
        const secondsPart = seconds % 60;
        return `${String(minutesPart).padStart(2, '0')}:${String(secondsPart).padStart(2, '0')}`;
    }

    function renderDockCounter() {
        const counter = document.querySelector('#pmq-counter');
        if (!counter) return;

        const remaining = readCooldownUntil() - Date.now();
        counter.textContent = remaining > 0 ? formatRemaining(remaining) : '00:00';
        counter.dataset.active = remaining > 0 ? 'true' : 'false';
        counter.title = remaining > 0 ? t('counterRemaining') : t('counterReady');
    }

    function openDownload(file) {
        const tab = GM_openInTab(file.href, {
            active: false,
            insert: true,
            setParent: true
        });

        window.setTimeout(() => {
            try {
                tab?.close();
            } catch (_) {
                // 다운로드 응답 때문에 탭이 이미 닫혔다면 무시합니다.
            }
        }, DOWNLOAD_TAB_LIFETIME_MS);
    }

    function updateFormForCurrentPage() {
        const detected = detectCurrentPackage();
        const startInput = document.querySelector('#pmq-from');
        const endInput = document.querySelector('#pmq-to');
        const job = readJob();

        updateDialogTitle(detected, job);
        if (job?.running) {
            startInput.value = job.start;
            endInput.value = job.end;
            return;
        }

        if (!detected) return;

        const parts = [...detected.files.keys()];
        const minimum = parts[0];
        const maximum = parts.at(-1);
        startInput.min = minimum;
        startInput.max = maximum;
        startInput.value = minimum;
        endInput.min = minimum;
        endInput.max = maximum;
        endInput.value = maximum;
    }

    function updateDialogTitle(detected = detectCurrentPackage(), job = readJob()) {
        const title = document.querySelector('#pmq-title');
        if (!title) return;

        const packageLabel = job?.running ? job.packageLabel : detected?.label;
        title.textContent = packageLabel
            ? t('packageTitle', { package: packageLabel })
            : t('genericTitle');
    }

    function applyLanguageToUI() {
        const language = getLanguage();
        const dialog = document.querySelector('#pmq-dialog');
        const languageSelect = document.querySelector('#pmq-language');
        if (!dialog || !languageSelect) return;

        dialog.lang = language;
        languageSelect.value = language;
        document.querySelector('#pmq-close').ariaLabel = t('close');
        document.querySelector('#pmq-language-label').textContent = t('language');
        document.querySelector('#pmq-from-label').textContent = t('fromPart');
        document.querySelector('#pmq-to-label').textContent = t('toPart');
        document.querySelector('#pmq-cancel').textContent = t('cancel');
        document.querySelector('#pmq-start').textContent = t('start');
        document.querySelector('#pmq-launcher').textContent = t('launcher');

        updateDialogTitle();
        renderStatus();
        renderDockCounter();
    }

    function renderStatus() {
        const status = document.querySelector('#pmq-status');
        const cancelButton = document.querySelector('#pmq-cancel');
        const startButton = document.querySelector('#pmq-start');
        if (!status || !cancelButton || !startButton) return;

        const job = readJob();
        if (!job?.running) {
            const detected = detectCurrentPackage();
            cancelButton.hidden = true;
            startButton.disabled = !detected || detected.files.size < 2;

            if (!detected) {
                status.textContent = t('setupRequired');
            } else if (detected.files.size < 2) {
                status.textContent = t('singleFile');
            } else {
                status.textContent = t('detected', {
                    package: detected.label,
                    count: detected.files.size
                });
            }
            status.dataset.state = 'idle';
            return;
        }

        cancelButton.hidden = false;
        startButton.disabled = true;

        const nextFile = job.files?.[job.index];
        if (!nextFile) {
            status.textContent = t('allSent');
            status.dataset.state = 'done';
            return;
        }

        const remaining = Math.max(job.nextAt || 0, readCooldownUntil()) - Date.now();
        if (remaining > 0) {
            status.textContent = t('waiting', {
                package: job.packageLabel,
                part: formatPart(nextFile.part),
                time: formatRemaining(remaining)
            });
            status.dataset.state = 'waiting';
        } else {
            status.textContent = t('preparing', {
                package: job.packageLabel,
                part: formatPart(nextFile.part)
            });
            status.dataset.state = 'running';
        }
    }

    function finishJob(message) {
        clearJob();
        downloadInProgress = false;
        renderStatus();

        const status = document.querySelector('#pmq-status');
        if (status) {
            status.textContent = message;
            status.dataset.state = 'done';
        }
    }

    function scheduleTick(delay = 0) {
        if (timerId !== null) window.clearTimeout(timerId);
        timerId = window.setTimeout(tick, Math.max(0, delay));
    }

    function claimOrRefreshOwnership(job) {
        const now = Date.now();
        if (job.ownerId === INSTANCE_ID) {
            if (now - (job.heartbeatAt || 0) >= 5000) {
                job = { ...job, heartbeatAt: now };
                writeJob(job);
            }
            return job;
        }

        const ownerIsStale = now - (job.heartbeatAt || 0) > OWNER_STALE_MS;
        if (ownerIsStale && !document.hidden) {
            job = { ...job, ownerId: INSTANCE_ID, heartbeatAt: now };
            writeJob(job);
            return job;
        }

        return null;
    }

    function tick() {
        timerId = null;
        renderStatus();
        renderDockCounter();

        let job = readJob();
        if (!job?.running || downloadInProgress) return;

        job = claimOrRefreshOwnership(job);
        if (!job) {
            scheduleTick(1000);
            return;
        }

        const file = job.files?.[job.index];
        if (!file) {
            finishJob(t('rangeComplete', {
                package: job.packageLabel,
                start: formatPart(job.start),
                end: formatPart(job.end)
            }));
            return;
        }

        const nextAllowedAt = Math.max(job.nextAt || 0, readCooldownUntil());
        const remaining = nextAllowedAt - Date.now();
        if (remaining > 0) {
            scheduleTick(Math.min(remaining, 1000));
            return;
        }

        downloadInProgress = true;
        try {
            openDownload(file);
            const cooldownUntil = startCooldown();
            const nextJob = {
                ...job,
                index: job.index + 1,
                nextAt: cooldownUntil,
                lastRequested: file.part,
                heartbeatAt: Date.now()
            };
            writeJob(nextJob);
            downloadInProgress = false;

            if (nextJob.index >= nextJob.files.length) {
                finishJob(t('rangeComplete', {
                    package: nextJob.packageLabel,
                    start: formatPart(nextJob.start),
                    end: formatPart(nextJob.end)
                }));
            } else {
                renderStatus();
                scheduleTick(1000);
            }
        } catch (error) {
            downloadInProgress = false;
            finishJob(t('failed', {
                part: formatPart(file.part),
                error: error?.message || error
            }));
        }
    }

    function startQueue() {
        const detected = detectCurrentPackage();
        const startInput = document.querySelector('#pmq-from');
        const endInput = document.querySelector('#pmq-to');
        const status = document.querySelector('#pmq-status');
        const start = Number(startInput?.value);
        const end = Number(endInput?.value);

        if (!detected || detected.files.size < 2) {
            status.textContent = t('multipartRequired');
            status.dataset.state = 'error';
            return;
        }

        if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
            status.textContent = t('invalidRange');
            status.dataset.state = 'error';
            return;
        }

        const selectedFiles = [];
        const missing = [];
        for (let part = start; part <= end; part += 1) {
            const file = detected.files.get(part);
            if (file) selectedFiles.push(file);
            else missing.push(formatPart(part));
        }

        if (missing.length) {
            status.textContent = t('missingLinks', { parts: missing.join(', ') });
            status.dataset.state = 'error';
            return;
        }

        writeJob({
            running: true,
            packageId: detected.id,
            packageLabel: detected.label,
            start,
            end,
            files: selectedFiles,
            index: 0,
            nextAt: Math.max(Date.now(), readCooldownUntil()),
            createdAt: Date.now(),
            ownerId: INSTANCE_ID,
            heartbeatAt: Date.now()
        });

        renderStatus();
        scheduleTick(0);
    }

    function cancelQueue() {
        clearJob();
        downloadInProgress = false;
        if (timerId !== null) window.clearTimeout(timerId);
        timerId = null;
        updateFormForCurrentPage();
        renderStatus();

        const status = document.querySelector('#pmq-status');
        if (status) {
            status.textContent = t('canceled');
            status.dataset.state = 'idle';
        }
    }

    function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            #pmq-overlay {
                position: fixed;
                inset: 0;
                z-index: 2147483646;
                display: grid;
                place-items: center;
                background: rgba(0, 0, 0, .66);
                backdrop-filter: blur(4px);
                font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
            }
            #pmq-overlay[hidden] { display: none; }
            #pmq-dialog {
                width: min(430px, calc(100vw - 32px));
                color: #f5f5f5;
                background: #101010;
                border: 1px solid #383838;
                border-radius: 16px;
                box-shadow: 0 24px 80px rgba(0, 0, 0, .65);
                overflow: hidden;
            }
            #pmq-head {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 18px 20px;
                border-bottom: 1px solid #2b2b2b;
            }
            #pmq-title { margin: 0; font-size: 18px; font-weight: 750; }
            #pmq-close {
                width: 32px;
                height: 32px;
                color: #aaa;
                background: transparent;
                border: 0;
                border-radius: 8px;
                font-size: 24px;
                line-height: 1;
                cursor: pointer;
            }
            #pmq-close:hover { color: white; background: #252525; }
            #pmq-body { padding: 20px; }
            .pmq-label { display: block; margin: 0 0 8px; color: #bbb; font-size: 13px; }
            #pmq-language-row {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 16px;
                margin-bottom: 18px;
            }
            #pmq-language-label { margin: 0; }
            .pmq-number,
            #pmq-language {
                box-sizing: border-box;
                height: 44px;
                color: #fff;
                background: #191919;
                border: 1px solid #3a3a3a;
                border-radius: 10px;
                padding: 0 12px;
                outline: none;
            }
            .pmq-number { width: 100%; }
            #pmq-language { width: 150px; height: 38px; }
            .pmq-number:focus,
            #pmq-language:focus { border-color: #ff5a00; box-shadow: 0 0 0 3px rgba(255, 90, 0, .15); }
            #pmq-range { display: grid; grid-template-columns: 1fr 28px 1fr; align-items: end; gap: 8px; }
            #pmq-range-separator { height: 44px; display: grid; place-items: center; color: #777; }
            #pmq-status {
                min-height: 20px;
                margin: 16px 0 0;
                padding: 12px;
                color: #bbb;
                background: #181818;
                border-radius: 10px;
                font-size: 13px;
                line-height: 1.5;
            }
            #pmq-status[data-state="waiting"] { color: #ffb27e; }
            #pmq-status[data-state="running"] { color: #ff6a16; }
            #pmq-status[data-state="done"] { color: #72d995; }
            #pmq-status[data-state="error"] { color: #ff7373; }
            #pmq-actions { display: flex; gap: 10px; margin-top: 18px; }
            .pmq-button {
                flex: 1;
                height: 44px;
                border: 0;
                border-radius: 10px;
                font-weight: 750;
                cursor: pointer;
            }
            #pmq-start { color: white; background: #f05400; }
            #pmq-start:hover { background: #ff6410; }
            #pmq-start:disabled { cursor: not-allowed; opacity: .45; }
            #pmq-cancel { color: #ddd; background: #292929; }
            #pmq-cancel:hover { background: #363636; }
            #pmq-dock {
                position: fixed;
                right: 22px;
                bottom: 22px;
                z-index: 2147483645;
                display: flex;
                align-items: center;
                gap: 7px;
                font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif;
            }
            #pmq-counter {
                min-width: 34px;
                color: #777;
                font: 650 10px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
                letter-spacing: -.3px;
                text-align: right;
                opacity: .82;
                user-select: none;
            }
            #pmq-counter[data-active="true"] { color: #ff9a5f; }
            #pmq-launcher {
                height: 44px;
                padding: 0 16px;
                color: #fff;
                background: #f05400;
                border: 0;
                border-radius: 22px;
                box-shadow: 0 8px 30px rgba(0, 0, 0, .4);
                font-weight: 750;
                cursor: pointer;
            }
        `;
        document.head.appendChild(style);
    }

    function injectUI() {
        const overlay = document.createElement('div');
        overlay.id = 'pmq-overlay';
        overlay.hidden = true;
        overlay.innerHTML = `
            <section id="pmq-dialog" role="dialog" aria-modal="true" aria-labelledby="pmq-title">
                <header id="pmq-head">
                    <h2 id="pmq-title">ProMods Download Queue</h2>
                    <button id="pmq-close" type="button" aria-label="Close">×</button>
                </header>
                <div id="pmq-body">
                    <div id="pmq-language-row">
                        <label id="pmq-language-label" class="pmq-label" for="pmq-language">Language</label>
                        <select id="pmq-language">
                            <option value="en">English</option>
                            <option value="ko">한국어</option>
                        </select>
                    </div>

                    <div id="pmq-range">
                        <div>
                            <label id="pmq-from-label" class="pmq-label" for="pmq-from">From part</label>
                            <input id="pmq-from" class="pmq-number" type="number" min="1" value="1" inputmode="numeric">
                        </div>
                        <div id="pmq-range-separator">→</div>
                        <div>
                            <label id="pmq-to-label" class="pmq-label" for="pmq-to">To part</label>
                            <input id="pmq-to" class="pmq-number" type="number" min="1" value="1" inputmode="numeric">
                        </div>
                    </div>

                    <p id="pmq-status" data-state="idle"></p>

                    <div id="pmq-actions">
                        <button id="pmq-cancel" class="pmq-button" type="button" hidden>Cancel Queue</button>
                        <button id="pmq-start" class="pmq-button" type="button">Start</button>
                    </div>
                </div>
            </section>
        `;

        const dock = document.createElement('div');
        dock.id = 'pmq-dock';
        dock.innerHTML = `
            <span id="pmq-counter" data-active="false" title="Download available">00:00</span>
            <button id="pmq-launcher" type="button">Download Queue</button>
        `;

        document.body.append(overlay, dock);

        document.querySelector('#pmq-close').addEventListener('click', () => {
            overlay.hidden = true;
        });
        document.querySelector('#pmq-launcher').addEventListener('click', () => {
            updateFormForCurrentPage();
            renderStatus();
            overlay.hidden = false;
            document.querySelector('#pmq-from')?.focus();
        });
        document.querySelector('#pmq-start').addEventListener('click', startQueue);
        document.querySelector('#pmq-cancel').addEventListener('click', cancelQueue);
        document.querySelector('#pmq-language').addEventListener('change', event => {
            setLanguage(event.target.value);
            applyLanguageToUI();
        });
        document.querySelector('#pmq-dialog').addEventListener('keydown', event => {
            if (event.key === 'Enter' && !readJob()?.running) {
                event.preventDefault();
                startQueue();
            }
            if (event.key === 'Escape') overlay.hidden = true;
        });

        updateFormForCurrentPage();
        applyLanguageToUI();
    }

    function watchManualDownloads() {
        document.addEventListener('click', event => {
            const link = event.target.closest?.('a.pm-dl-btn.free[href*="getdl.php"]');
            if (link) startCooldown();
        }, true);
    }

    function init() {
        if (GM_getValue(LEGACY_JOB_KEY, null)) GM_deleteValue(LEGACY_JOB_KEY);

        injectStyles();
        injectUI();
        watchManualDownloads();

        if (readJob()?.running) scheduleTick(0);
        uiIntervalId = window.setInterval(() => {
            renderDockCounter();
            const overlay = document.querySelector('#pmq-overlay');
            if (overlay && !overlay.hidden) renderStatus();
        }, 1000);

        document.addEventListener('visibilitychange', () => {
            if (!document.hidden && readJob()?.running) scheduleTick(0);
        });

        window.addEventListener('beforeunload', () => {
            if (timerId !== null) window.clearTimeout(timerId);
            if (uiIntervalId !== null) window.clearInterval(uiIntervalId);
        }, { once: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})();