Greasy Fork

Greasy Fork is available in English.

动画疯弹幕字体自定义 + 长按方向右键倍速播放

自定义动画疯弹幕字体,并增加长按方向右键倍速播放功能

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         动画疯弹幕字体自定义 + 长按方向右键倍速播放
// @namespace    ani.gamer.com.tw.danmufontdefine.longpressboost
// @version      1.3
// @description  自定义动画疯弹幕字体,并增加长按方向右键倍速播放功能
// @author       atSeiunSky
// @match        https://ani.gamer.com.tw/animeVideo.php?sn=*
// @match        https://ani.gamer.com.tw/playerVideo.php?sn=*
// @icon         https://ani.gamer.com.tw/favicon.ico
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = 'tmDanmakuFontFamily';
    const SPEED_STORAGE_KEY = 'tmPlaybackBoostRate';
    const SAMPLE_FONT = '"Noto Sans SC","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif';
    const DANMU_SELECTOR = '.danmu-warp .danmu';
    const POLL_INTERVAL = 1200;
    const BOOST_DEFAULT_RATE = 2;
    const LONG_PRESS_DELAY = 220;
    const SYNTHETIC_EVENT_FLAG = '__tmArrowRightSynthetic';

    const parseBoostRate = (value) => {
        const numeric = typeof value === 'number' ? value : parseFloat(value);
        if (!Number.isFinite(numeric) || numeric <= 0) return BOOST_DEFAULT_RATE;
        return Math.min(16, numeric);
    };

    const loadBoostRate = () => parseBoostRate(GM_getValue(SPEED_STORAGE_KEY, BOOST_DEFAULT_RATE));
    const now = () => (typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now());

    let currentFont = GM_getValue(STORAGE_KEY, '');
    let danmuObserver = null;
    let observedRoot = null;
    let openPanel = () => console.warn('[DanmuFont] 面板尚未加载完成');
    let boostRate = loadBoostRate();
    let boostActive = false;
    let boostTargetVideo = null;
    let arrowRightPressed = false;
    let arrowRightTimer = null;
    let arrowRightEventTarget = null;
    const boostContext = { video: null, previousRate: 1 };

    const ready = (cb) => {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', cb, { once: true });
        } else {
            cb();
        }
    };

    const swallowEvent = (event) => {
        if (!event) return;
        event.preventDefault();
        event.stopPropagation();
        if (typeof event.stopImmediatePropagation === 'function') {
            event.stopImmediatePropagation();
        }
    };

    const getActiveVideo = () => {
        const pipVideo = document.pictureInPictureElement;
        if (pipVideo instanceof HTMLVideoElement) return pipVideo;
        const videos = Array.from(document.querySelectorAll('video'));
        if (!videos.length) return null;
        const playing = videos.find((video) => !video.paused && !video.ended);
        return playing || videos[0];
    };

    const isArrowRightEvent = (event) => {
        if (!event) return false;
        if (event[SYNTHETIC_EVENT_FLAG]) return false;
        return event.key === 'ArrowRight' || event.code === 'ArrowRight' || event.keyCode === 39;
    };

    const markSynthetic = (event) => {
        try {
            Object.defineProperty(event, SYNTHETIC_EVENT_FLAG, { value: true });
        } catch (error) {
            event[SYNTHETIC_EVENT_FLAG] = true;
        }
        try {
            if (event.keyCode !== 39) {
                Object.defineProperty(event, 'keyCode', { get: () => 39 });
            }
            if (event.which !== 39) {
                Object.defineProperty(event, 'which', { get: () => 39 });
            }
        } catch (_) {
            // ignore
        }
    };

    const createSyntheticArrowEvent = (type) => {
        const synthetic = new KeyboardEvent(type, {
            key: 'ArrowRight',
            code: 'ArrowRight',
            keyCode: 39,
            which: 39,
            bubbles: true,
            cancelable: true,
        });
        markSynthetic(synthetic);
        return synthetic;
    };

    const dispatchNativeArrowRight = (preferredTarget) => {
        const target = preferredTarget || document.activeElement || document.body || document;
        if (!target || typeof target.dispatchEvent !== 'function') return;
        const keydown = createSyntheticArrowEvent('keydown');
        const keyup = createSyntheticArrowEvent('keyup');
        target.dispatchEvent(keydown);
        target.dispatchEvent(keyup);
    };

    const activateBoost = (preferredVideo) => {
        const video = preferredVideo || document.querySelector('video');
        if (!video) return false;
        boostContext.video = video;
        boostContext.previousRate = video.playbackRate || 1;
        boostActive = true;
        video.playbackRate = boostRate;
        console.info(`[DanmuBoost] 临时倍速 x${boostRate}`);
        return true;
    };

    const deactivateBoost = () => {
        if (!boostActive) {
            boostTargetVideo = null;
            return;
        }
        const { video, previousRate } = boostContext;
        if (video) {
            video.playbackRate = previousRate || 1;
        }
        boostContext.video = null;
        boostContext.previousRate = 1;
        boostActive = false;
        boostTargetVideo = null;
    };

    const clearArrowRightTimer = () => {
        if (arrowRightTimer) {
            window.clearTimeout(arrowRightTimer);
            arrowRightTimer = null;
        }
    };

    const cancelBoostFlow = () => {
        boostTargetVideo = null;
        arrowRightPressed = false;
        arrowRightEventTarget = null;
        clearArrowRightTimer();
        deactivateBoost();
    };

    const handleArrowRightKeyDown = (event) => {
        if (event[SYNTHETIC_EVENT_FLAG]) return;
        if (!isArrowRightEvent(event)) return;
        const video = getActiveVideo();
        if (!video) return;
        swallowEvent(event);
        if (!arrowRightPressed) {
            arrowRightPressed = true;
            boostTargetVideo = video;
            arrowRightEventTarget = event.target || document.activeElement || document.body || document;
            clearArrowRightTimer();
            arrowRightTimer = window.setTimeout(() => {
                arrowRightTimer = null;
                if (!arrowRightPressed) return;
                activateBoost(boostTargetVideo);
            }, LONG_PRESS_DELAY);
        }
    };

    const handleArrowRightKeyUp = (event) => {
        if (event[SYNTHETIC_EVENT_FLAG]) return;
        if (!isArrowRightEvent(event)) return;
        if (!arrowRightPressed && !boostActive) return;
        swallowEvent(event);
        const wasPressed = arrowRightPressed;
        arrowRightPressed = false;
        clearArrowRightTimer();
        const wasBoosting = boostActive;
        if (boostActive) {
            deactivateBoost();
        }
        if (wasBoosting) {
            boostTargetVideo = null;
            arrowRightEventTarget = null;
            return;
        }
        if (!wasPressed) {
            arrowRightEventTarget = null;
            boostTargetVideo = null;
            return;
        }
        boostTargetVideo = null;
        const dispatchTarget = arrowRightEventTarget;
        arrowRightEventTarget = null;
        dispatchNativeArrowRight(dispatchTarget);
    };

    const promptBoostRate = () => {
        const input = window.prompt('请输入长按方向键 → 时希望使用的临时倍速(> 0)', String(boostRate));
        if (input === null) return;
        const trimmed = input.trim();
        if (!trimmed) {
            window.alert('请输入有效数字,例如 1.5 或 2');
            return;
        }
        const numeric = parseFloat(trimmed);
        if (!Number.isFinite(numeric) || numeric <= 0) {
            window.alert('请输入大于 0 的数字,例如 1.5 或 2');
            return;
        }
        boostRate = parseBoostRate(numeric);
        GM_setValue(SPEED_STORAGE_KEY, boostRate);
        console.info(`[DanmuBoost] 长按倍速已保存为 x${boostRate}`);
    };

    const initBoostHotkeys = () => {
        document.addEventListener('keydown', handleArrowRightKeyDown, true);
        document.addEventListener('keyup', handleArrowRightKeyUp, true);
        window.addEventListener('blur', cancelBoostFlow, true);
        document.addEventListener('visibilitychange', () => {
            if (document.hidden) {
                cancelBoostFlow();
            }
        });
    };

    const normalizeFontValue = (value) => {
        if (!value) return '';
        const trimmed = value.trim();
        if (!trimmed || trimmed.toLowerCase() === 'inherit') return '';
        return trimmed.replace(/\s*,\s*/g, ', ').replace(/\s+/g, ' ');
    };

    const applyFontToNode = (node) => {
        if (!node || node.nodeType !== 1) return;
        if (!currentFont) {
            node.style.removeProperty('font-family');
            node.style.removeProperty('text-rendering');
            node.style.removeProperty('font-kerning');
            node.removeAttribute('data-tm-font');
            return;
        }
        node.style.fontFamily = currentFont;
        node.style.textRendering = 'optimizeSpeed';
        node.style.fontKerning = 'none';
        node.dataset.tmFont = currentFont;
    };

    const repaintAll = () => {
        document.querySelectorAll(DANMU_SELECTOR).forEach(applyFontToNode);
    };

    const handleDanmuMutations = (mutations) => {
        if (!currentFont) return;
        for (const mutation of mutations) {
            mutation.addedNodes.forEach((node) => {
                if (node.nodeType !== 1) return;
                if (node.classList.contains('danmu')) {
                    applyFontToNode(node);
                } else if (node.querySelectorAll) {
                    node.querySelectorAll('.danmu').forEach(applyFontToNode);
                }
            });
        }
    };

    const ensureDanmuObserver = () => {
        const root = document.querySelector('.danmu-warp');
        if (root === observedRoot) return;
        if (danmuObserver) {
            danmuObserver.disconnect();
            danmuObserver = null;
        }
        observedRoot = root;
        if (!root) return;
        danmuObserver = new MutationObserver(handleDanmuMutations);
        danmuObserver.observe(root, { childList: true, subtree: true });
        repaintAll();
    };

    const pollDanmuRoot = () => {
        ensureDanmuObserver();
        window.setTimeout(pollDanmuRoot, POLL_INTERVAL);
    };

    const updateFont = (value) => {
        currentFont = normalizeFontValue(value);
        GM_setValue(STORAGE_KEY, currentFont);
        repaintAll();
        console.info('[DanmuFont] 已套用', currentFont || '站内默认字体');
    };

    const injectStyle = (css) => {
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
    };

    const buildPanel = () => {
        injectStyle(`
            .tm-danmu-font-panel {
                position: fixed;
                top: 72px;
                right: 24px;
                width: 320px;
                max-width: calc(100vw - 32px);
                padding: 16px;
                border-radius: 12px;
                background: rgba(18, 18, 24, 0.95);
                color: #fff;
                box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
                font-family: "PingFang TC","Microsoft JhengHei",sans-serif;
                z-index: 2147483647;
                display: none;
                flex-direction: column;
                gap: 12px;
            }
            .tm-danmu-font-panel.is-open { display: flex; }
            .tm-danmu-font-panel__title { font-size: 16px; font-weight: 600; }
            .tm-danmu-font-panel input {
                width: 100%;
                padding: 8px 10px;
                border-radius: 6px;
                border: 1px solid rgba(255, 255, 255, 0.25);
                background: rgba(0, 0, 0, 0.35);
                color: #fff;
                font-size: 13px;
            }
            .tm-danmu-font-panel__hint {
                font-size: 12px;
                line-height: 1.4;
                color: rgba(255, 255, 255, 0.65);
                margin: 0;
            }
            .tm-danmu-font-panel__actions {
                display: flex;
                gap: 8px;
                justify-content: flex-end;
                flex-wrap: wrap;
            }
            .tm-danmu-font-panel button {
                border: none;
                border-radius: 6px;
                padding: 6px 12px;
                font-size: 13px;
                cursor: pointer;
            }
            .tm-danmu-font-panel button[data-variant="ghost"] {
                background: transparent;
                color: #fff;
                border: 1px solid rgba(255, 255, 255, 0.3);
            }
            .tm-danmu-font-panel button[data-variant="primary"] {
                background: #ffb347;
                color: #1d1d1d;
                font-weight: 600;
            }
        `);

        const panel = document.createElement('div');
        panel.className = 'tm-danmu-font-panel';
        panel.innerHTML = `
            <div class="tm-danmu-font-panel__title">自订弹幕字体</div>
            <input type="text" placeholder='${SAMPLE_FONT}'>
            <p class="tm-danmu-font-panel__hint">
                可输入多个字体并用英文逗号分隔;例如:"LXGW WenKai","Noto Sans SC",sans-serif
            </p>
            <div class="tm-danmu-font-panel__actions">
                <button type="button" data-action="sample" data-variant="ghost">填入示例</button>
                <button type="button" data-action="reset" data-variant="ghost">恢复默认</button>
                <button type="button" data-action="close" data-variant="ghost">取消</button>
                <button type="button" data-action="apply" data-variant="primary">套用</button>
            </div>
        `;
        document.body.appendChild(panel);

        const input = panel.querySelector('input');
        const closePanel = () => panel.classList.remove('is-open');

        openPanel = () => {
            panel.classList.add('is-open');
            input.value = currentFont || '';
            requestAnimationFrame(() => input.focus({ preventScroll: true }));
        };

        panel.addEventListener('click', (event) => {
            const button = event.target;
            if (!button || !button.dataset) return;
            const action = button.dataset.action;
            if (action === 'apply') {
                updateFont(input.value);
                closePanel();
            } else if (action === 'reset') {
                updateFont('');
                closePanel();
            } else if (action === 'close') {
                closePanel();
            } else if (action === 'sample') {
                input.value = SAMPLE_FONT;
                input.focus();
                input.select();
            }
        });

        panel.addEventListener('keydown', (event) => {
            if (event.key === 'Escape') {
                event.preventDefault();
                closePanel();
            } else if (event.key === 'Enter' && event.target === input) {
                event.preventDefault();
                updateFont(input.value);
                closePanel();
            }
        });
    };

    ready(() => {
        buildPanel();
        repaintAll();
        pollDanmuRoot();
        initBoostHotkeys();
        GM_registerMenuCommand('设置弹幕字体…', () => openPanel());
        GM_registerMenuCommand('恢复站内字体', () => updateFont(''));
        GM_registerMenuCommand('套用示例字体栈', () => updateFont(SAMPLE_FONT));
        GM_registerMenuCommand('设置方向键长按倍速…', () => promptBoostRate());
    });
})();