tweaks for redgifs page
// ==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();