Redgifs Tweaks

tweaks for redgifs page

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name                Redgifs Tweaks
// @namespace           https://greasyfork.org/users/821661
// @match               https://www.redgifs.com/*
// @exclude-match       https://www.redgifs.com/ifr/*
// @grant               none
// @run-at              document-start
// @version             1.0.0
// @author              hdyzen
// @description         tweaks for redgifs page
// @license             MIT
// @noframes
// ==/UserScript==

// ============================================================
// CORE
// ============================================================

class RedGifsTweaks {
    config = { FILENAME_FORMAT: "{id}.{ext}" };
    bus = new EventTarget();
    currentGifId = null;
    gifs = createGifStore();

    start() {
        this.intercept(JSON, "parse", "json", ({ args, result }) => ({ text: args[0], result }));
        this.intercept(XMLHttpRequest.prototype, "open", "xhr", ({ args, self }) => ({
            xhr: self,
            url: args[1],
            method: args[0],
        }));
        this.listenToMutation();
    }

    intercept(obj, key, event, mapFn) {
        const original = obj[key];
        obj[key] = new Proxy(original, {
            apply: (t, thisArg, args) => {
                const result = Reflect.apply(t, thisArg, args);
                this.emit(event, mapFn({ target: original, args, self: thisArg, result }));
                return result;
            },
        });
    }

    on(type, listener) {
        this.bus.addEventListener(type, ({ detail }) => listener(detail));
    }

    emit(type, detail) {
        this.bus.dispatchEvent(new CustomEvent(type, { detail }));
    }

    listenToMutation() {
        const observer = new MutationObserver((mutations) => {
            this.emit("mutation", { mutations, observer });
        });
        document.addEventListener("DOMContentLoaded", () => {
            observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
        });
    }
}

function createGifStore() {
    const add = ({ id, userName, niches, sexuality, tags, createDate, duration, urls }) => {
        const sources = [];
        for (const [type, url] of Object.entries(urls)) {
            if (type === "html") continue;
            sources.push({ type, url, ext: url.split(".").at(-1) });
        }
        gifs.set(id, { id, user: userName, niches, sexuality, tags, timestamp: createDate, duration, sources });
    };
    const gifs = new Map();
    return { add, get: (id) => gifs.get(id) };
}

function waitFor(condition, timeout = 1000, interval = 50) {
    return new Promise((resolve, reject) => {
        const intervalFn = setInterval(() => {
            if (condition) return resolve(condition);
            if (timeout <= 0) return reject();
        }, interval);
        setTimeout(() => clearInterval(intervalFn) || reject(), timeout);
    });
}

// ============================================================
// PLUGIN SYSTEM
// ============================================================

class PluginManager {
    plugins = new Map();

    add(def) {
        if (this.plugins.has(def.id)) return;

        if (def.depends) {
            for (const dep of def.depends) {
                if (!this.plugins.has(dep)) {
                    console.error(`Plugin "${def.id}" depends on "${dep}" — not added`);
                    return;
                }
            }
        }

        const handle = {
            _setup: null,
            _teardown: null,
            _listeners: [],
            on: (type, fn) => {
                if (type === "setup") handle._setup = fn;
                else if (type === "teardown") handle._teardown = fn;
                else handle._listeners.push([type, fn]);
                return handle;
            },
        };

        this.plugins.set(def.id, handle);
        return handle;
    }

    init() {
        for (const handle of this.plugins.values()) {
            handle._setup?.();
            for (const [type, fn] of handle._listeners) {
                app.on(type, fn);
            }
        }
    }
}

const app = new RedGifsTweaks();
const plugins = new PluginManager();

// ============================================================
// GLOBAL STYLES
// ============================================================

const DOWNLOAD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M12.554 16.506a.75.75 0 0 1-1.107 0l-4-4.375a.75.75 0 0 1 1.107-1.012l2.696 2.95V3a.75.75 0 0 1 1.5 0v11.068l2.697-2.95a.75.75 0 1 1 1.107 1.013z"/><path fill="#fff" d="M3.75 15a.75.75 0 0 0-1.5 0v.055c0 1.367 0 2.47.117 3.337.12.9.38 1.658.981 2.26.602.602 1.36.86 2.26.982.867.116 1.97.116 3.337.116h6.11c1.367 0 2.47 0 3.337-.116.9-.122 1.658-.38 2.26-.982s.86-1.36.982-2.26c.116-.867.116-1.97.116-3.337V15a.75.75 0 0 0-1.5 0c0 1.435-.002 2.436-.103 3.192-.099.734-.28 1.122-.556 1.399-.277.277-.665.457-1.4.556-.755.101-1.756.103-3.191.103H9c-1.435 0-2.437-.002-3.192-.103-.734-.099-1.122-.28-1.399-.556-.277-.277-.457-.665-.556-1.4-.101-.755-.103-1.756-.103-3.191"/></svg>`;

const style = document.createElement("style");
style.textContent = `
    .DownloadButton {
        anchor-name: --download-button;
        background: none;
        border: none;
    }

    #downloadMenu {
        position: absolute;
        anchor-position: --download-button;
        position-area: center left;

        background: rgb(10, 10, 10, .8);
        backdrop-filter: blur(20px);
        border: 1px solid #333;
        border-radius: 0.5rem;
        padding: 0.25rem;
        box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5);
        display: flex;
        flex-direction: column;
        gap: 4px;

        opacity: 0;
        transition-property: opacity, display, overlay;
        transition-duration: 0.2s;
        transition-behavior: allow-discrete;
    }

    #downloadMenu:popover-open {
        opacity: 1;
        @starting-style {
            opacity: 0;
        }
    }

    #downloadMenu button {
        position: relative;
        overflow: hidden;
        background: transparent;
        color: #e0e0e0;
        border: none;
        padding: 0.25rem 0.5rem;
        border-radius: 0.25rem;
        font-size: 1rem;
        text-align: center;
        transition: background-color 0.2s, color 0.2s;

        &::after {
            content: "";
            position: absolute;
            left: 0;
            bottom: 0;
            width: var(--loaded, 0);
            height: 2px;
            background: rgb(255, 255, 255, .5);
            transition: background-color 0.2s;
        }
    }

    #downloadMenu button:hover {
        background: rgb(255, 255, 255, .1);
    }

    /* Plugin: Volume slider */

    .sideBarItem:has(.SoundButton) {
        position: relative;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    .custom-volume-wrapper {
        position: absolute;
        right: 0;
        top: 50%;
        transform: translateY(-50%);
        height: 48px; /* Altura padrão para cobrir o botão */
        width: 48px; /* Esconde atrás do botão original */
        background: transparent;
        border-radius: 9999px;
        display: flex;
        align-items: center;
        justify-content: flex-start;
        padding-right: 48px; /* Espaço para o botão real ficar livre à direita */
        box-sizing: border-box;
        transition: width 0.3s ease-out, background-color 0.3s ease-out;
        z-index: 1;
        pointer-events: none; /* Deixa o hover passar para o botão original no começo */
    }

    .sideBarItem:has(.SoundButton):hover .custom-volume-wrapper {
        width: 140px;
        background: rgba(0, 0, 0, 0.8);
        pointer-events: auto; /* Permite arrastar o controle de volume */
    }

    .SoundButton {
        z-index: 2;
        position: relative;
    }

    .custom-volume-input {
        width: 0;
        opacity: 0;
        height: 4px;
        background: #52525b; /* bg-zinc-600 */
        border-radius: 4px;
        appearance: none;
        outline: none;
        transition: all 0.3s ease-out;
        cursor: pointer;
        margin-left: 14px;
        accent-color: #ffffff;
    }

    .custom-volume-input::-webkit-slider-thumb {
        appearance: none;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        background: #ffffff;
        cursor: pointer;
    }

    .sideBarItem:has(.SoundButton):hover .custom-volume-input {
        width: 70px;
        opacity: 1;
        transition-delay: 0.05s; /* Atraso sutil para o fundo expandir primeiro */
    }

    /* Fix: Fixes buttons with SVG icons */
    button svg {
        vertical-align: middle;
    }

    [hidden] {
        display: none !important;
    }
`;
document.documentElement.appendChild(style);

// ============================================================
// PLUGINS
// ============================================================

// --- removeBoosted ---
plugins.add({ id: "removeBoosted", defaultEnabled: true }).on("json", ({ result }) => {
    if (result.boosted_gifs) result.boosted_gifs = [];
    if (result.promoted_gifs) result.promoted_gifs = [];
});

// --- exposeGifs ---
plugins
    .add({ id: "exposeGifs", defaultEnabled: true })
    .on("json", ({ result }) => {
        if (result.gif?.id && result.gif.urls) app.gifs.add(result.gif);
        if (!Array.isArray(result.gifs)) return;
        for (const gif of result.gifs) app.gifs.add(gif);
    })
    .on("mutation", () => {
        const el = document.querySelector("[data-feed-item-id]:not([exposed])");
        const data = el && app.gifs.get(el.dataset.feedItemId);
        if (!data) return;
        el.setAttribute("exposed", "");
        el.data = data;
    });

// --- disableLoop ---
// plugins.add({ id: "disableLoop", defaultEnabled: false }).on("mutation", () => {
//     const video = document.querySelector("video.isLoaded[loop]");
//     if (!video) return;
//     video.loop = false;
// });

// --- download ---
plugins
    .add({ id: "download", defaultEnabled: true, depends: ["exposeGifs"] })
    .on("setup", () => {
        const popover = document.createElement("div");
        popover.id = "downloadMenu";
        popover.popover = "";
        document.documentElement.appendChild(popover);

        popover.addEventListener("beforetoggle", (ev) => {
            if (ev.newState !== "open") return;
            populatePopover();
        });

        popover.addEventListener("click", (e) => {
            const btn = e.target.closest("button");
            if (!btn) return;
            downloadFile({
                fileName: formatFilename(app.currentGifId, btn.dataset.type),
                url: btn.dataset.url,
                onProgress: (pct) => btn.style.setProperty("--loaded", `${pct * 100}%`),
            });
        });

        const formatFilename = (gifId, sourceType) => {
            const gif = app.gifs.get(gifId);
            if (!gif) return;
            const ext = gif.sources.find((s) => s.type === sourceType)?.ext ?? "";
            const vars = { id: gifId, ext, timestamp: gif.timestamp, user: gif.user, duration: gif.duration };
            return app.config.FILENAME_FORMAT.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? "");
        };

        const downloadFile = ({ fileName, url, onProgress, onComplete, onError }) => {
            const req = new XMLHttpRequest();
            req.open("GET", url, true);
            req.responseType = "blob";
            req.onprogress = (e) => {
                if (e.lengthComputable) onProgress?.(e.loaded / e.total);
            };
            req.onload = () => {
                if (req.status !== 200) {
                    onError?.(new Error(`HTTP ${req.status}`));
                    return;
                }
                const blob = req.response;
                const url = URL.createObjectURL(blob);
                const link = document.createElement("a");
                link.href = url;
                link.download = fileName;
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
                URL.revokeObjectURL(url);
                onComplete?.(blob);
            };
            req.send();
        };

        const populatePopover = () => {
            const gif = app.gifs.get(app.currentGifId);
            if (!gif) return;
            popover.innerHTML = gif.sources.map((s) => `<button type="button" data-url="${s.url}" data-type="${s.type}">${s.type}</button>`).join("");
        };
    })
    .on("mutation", () => {
        const sidebar = document.querySelector(".sideBar:not(:has(.DownloadButton)) .sideBarItem:has(.SoundButton, .FSButton)");
        const gif = sidebar?.closest("[data-feed-item-id]");
        if (!gif) return;
        const item = document.createElement("li");
        item.className = "sideBarItem";
        item.innerHTML = `
        <button class="DownloadButton" popovertarget="downloadMenu">
            ${DOWNLOAD_ICON_SVG}
        </button>
        `;
        item.addEventListener("click", () => {
            app.currentGifId = gif.dataset.feedItemId;
        });
        sidebar.insertAdjacentElement("afterend", item);
    });

// --- volumeSlider ---
plugins.add({ id: "volumeSlider", defaultEnabled: true }).on("mutation", () => {
    const soundButton = document.querySelector(".SoundButton:not([data-vol-injected])");
    if (!soundButton) return;

    const video = document.querySelector(".GifPreview_isActive video");
    if (!video) return;

    soundButton.setAttribute("data-vol-injected", "true");

    const parent = soundButton.parentElement;

    const wrapper = document.createElement("div");
    wrapper.className = "custom-volume-wrapper";

    const input = document.createElement("input");
    input.type = "range";
    input.min = "0";
    input.max = "100";
    input.className = "custom-volume-input";
    input.value = video.volume * 100;

    input.addEventListener("click", (e) => e.stopImmediatePropagation());
    input.addEventListener("mouseup", (e) => e.stopImmediatePropagation());

    input.addEventListener("input", (e) => {
        const vol = Number(e.target.value) / 100;

        const localVideo = document.querySelector(".GifPreview_isActive video");
        const soundButton = document.querySelector(".SoundButton");

        if (vol === 0 && !localVideo.muted) {
            soundButton.click();
        } else if (vol > 0 && localVideo.muted) {
            soundButton.click();
        }

        localVideo.volume = vol;
        localVideo.muted = vol === 0;
    });

    wrapper.addEventListener("click", (e) => e.stopPropagation());
    wrapper.addEventListener("mousedown", (e) => e.stopPropagation());

    wrapper.appendChild(input);
    parent.insertBefore(wrapper, soundButton);
});

// ============================================================
// INIT
// ============================================================

plugins.init();
app.start();