Adds a download button (for one-click HD downloads) and an "Open in New Tab" button to each video on the RedGIFs site.
// ==UserScript==
// @name RedGIFs Video Download Button
// @namespace https://github.com/p65536
// @version 2.3.0
// @license MIT
// @description Adds a download button (for one-click HD downloads) and an "Open in New Tab" button to each video on the RedGIFs site.
// @icon https://www.redgifs.com/favicon.ico
// @author p65536
// @match https://*.redgifs.com/*
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @grant unsafeWindow
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
'use strict';
// =================================================================================
// SECTION: Script-Specific Definitions
// =================================================================================
const OWNERID = 'p65536';
const APPID = 'rgvdb';
const APPNAME = 'RedGIFs Video Download Button';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
class HttpError extends Error {
/**
* @param {number} status
* @param {string} message
*/
constructor(status, message) {
super(message);
this.name = 'HttpError';
this.status = status;
}
}
// =================================================================================
// SECTION: Configuration Definitions
// =================================================================================
const CONSTANTS = {
CONFIG_KEY: `${APPID}_config`,
VIDEO_CONTAINER_SELECTOR: '.GifPreview',
TILE_ITEM_SELECTOR: '.tileItem',
WATCH_URL_BASE: 'https://www.redgifs.com/watch/',
TOAST_DURATION: 3000,
TOAST_ERROR_DURATION: 6000,
TOAST_FADE_OUT_DURATION: 300,
ICON_REVERT_DELAY: 2000,
CANCEL_LOCK_DURATION: 600, // (ms) Duration to lock download button to prevent mis-click cancel
CONTEXT_TYPE: {
TILE: 'TILE',
PREVIEW: 'PREVIEW',
},
MODAL: {
WIDTH: 400,
Z_INDEX: 10001,
},
};
/**
* Default configuration settings.
* Contains the initial values and descriptions for user-configurable options.
*/
const DEFAULT_CONFIG = {
/**
* General settings affecting all buttons/UI.
*/
common: {
/**
* If true, ALL buttons are hidden by default and only appear when hovering over the thumbnail/video.
* This applies globally to prevent layout misalignment.
* (On mobile, buttons are always visible regardless of this setting to prevent usability issues)
*/
showOnlyOnHover: false,
},
/**
* Settings related to the Download functionality.
*/
download: {
/**
* Template for the filename of downloaded videos.
* You can customize the format using the following placeholders:
* - {user}: The creator's username (e.g., "RedGifsOfficial")
* - {date}: The creation date (YYYYMMDD_HHMMSS)
* - {id}: The unique video ID (e.g., "watchfulwaiting")
*
* Default: '{user}_{date}_{id}'
*/
filenameTemplate: '{user}_{date}_{id}',
},
/**
* Settings related to the "Open in New Tab" functionality.
*/
openInNewTab: {
/**
* Set to false to completely remove this button.
* If disabled, the download button will automatically move up to take its place.
*/
enabled: true,
/**
* The type of viewer to open when clicking the "Open in New Tab" button.
* Options: 'default' (RedGIFs Watch Page), 'clean' (Video Player Only)
* Default: 'default'
*/
viewerType: 'default',
},
/**
* Developer settings for debugging.
*/
developer: {
/**
* Controls the verbosity of logs in the console.
* Options: 'error', 'warn', 'info', 'log', 'debug'
*/
logger_level: 'log',
},
};
const EVENTS = {
CONFIG_UPDATED: `${APPID}:configUpdated`,
CONFIG_SAVE_SUCCESS: `${APPID}:configSaveSuccess`,
};
// =================================================================================
// SECTION: Style Definitions
// =================================================================================
const UI_STYLES_TEMPLATE = `
/* Open in New Tab Button on Thumbnails */
${CONSTANTS.TILE_ITEM_SELECTOR} {
position: relative;
}
.${APPID}-open-in-new-tab-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
width: 28px;
height: 28px;
padding: 4px;
border-radius: 4px;
background-color: rgb(0 0 0 / 0.6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
text-decoration: none; /* For <a> tag */
color: inherit; /* Prevent blue link color */
}
.${APPID}-open-in-new-tab-btn:hover {
background-color: rgb(0 0 0 / 0.8);
}
/* Download Button on Thumbnails */
.${APPID}-tile-download-btn {
position: absolute;
top: 40px; /* Positioned below the open-in-new-tab button (8px + 28px + 4px) */
right: 8px;
z-index: 10;
width: 28px;
height: 28px;
padding: 0;
border-radius: 4px;
background-color: red;
border: none;
cursor: pointer;
display: grid;
place-items: center;
}
.${APPID}-tile-download-btn:hover {
background-color: #c00;
}
/* Buttons on Video Preview */
${CONSTANTS.VIDEO_CONTAINER_SELECTOR} {
position: relative;
}
.${APPID}-preview-open-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 90;
width: 32px;
height: 32px;
padding: 4px;
border-radius: 4px;
background-color: rgb(0 0 0 / 0.6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none; /* For <a> tag */
color: inherit; /* Prevent blue link color */
}
.${APPID}-preview-open-btn:hover {
background-color: rgb(0 0 0 / 0.8);
}
.${APPID}-preview-download-btn {
position: absolute;
top: 44px; /* Positioned below the open-in-new-tab button */
right: 8px;
z-index: 90;
width: 32px;
height: 32px;
padding: 0;
border-radius: 4px;
background-color: red;
border: none;
cursor: pointer;
display: grid;
place-items: center;
}
.${APPID}-preview-download-btn:hover {
background-color: #c00;
}
/* Spinner Animation */
.${APPID}-spinner {
animation: ${APPID}-spinner-rotate 1s linear infinite;
transform-origin: center;
}
/* Toast Notifications */
.${APPID}-toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.${APPID}-toast {
padding: 12px 18px;
border-radius: 6px;
color: white;
font-family: sans-serif;
font-size: 14px;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
animation: ${APPID}-toast-fade-in 0.3s ease-out;
}
.${APPID}-toast.exiting {
animation: ${APPID}-toast-fade-out 0.3s ease-in forwards;
}
.${APPID}-toast-success { background-color: rgb(40 167 69); }
.${APPID}-toast-error { background-color: rgb(220 53 69); }
.${APPID}-toast-info { background-color: rgb(23 162 184); }
/* Mobile: Adjust button position to avoid overlapping native UI */
.App.phone .${APPID}-preview-open-btn {
/* Offset by toolbar height (assumed 56px) + 8px original top */
top: 64px;
}
.App.phone .${APPID}-preview-download-btn {
/* Offset by toolbar height (assumed 56px) + 44px original top */
top: 100px;
}
/* Hide buttons when the site menu is active */
body:has(.activeBurgerMenu) .${APPID}-preview-open-btn,
body:has(.activeBurgerMenu) .${APPID}-preview-download-btn {
display: none !important;
}
/* Keyframes */
@keyframes ${APPID}-spinner-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes ${APPID}-toast-fade-in {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes ${APPID}-toast-fade-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100%); }
}
`;
// Dark theme styles specifically for the settings modal
const MODAL_STYLES = `
.${APPID}-modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgb(0 0 0 / 0.7);
z-index: ${CONSTANTS.MODAL.Z_INDEX};
display: flex; align-items: center; justify-content: center;
}
.${APPID}-modal-box {
background: #222; color: #eee;
width: ${CONSTANTS.MODAL.WIDTH}px;
max-width: 90vw;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.5);
display: flex; flex-direction: column;
font-family: sans-serif; font-size: 14px;
}
.${APPID}-modal-header {
padding: 12px 16px;
font-size: 1.1em; font-weight: bold;
border-bottom: 1px solid #444;
display: flex; justify-content: space-between; align-items: center;
}
.${APPID}-modal-content {
padding: 16px;
overflow-y: auto;
max-height: 80vh;
}
.${APPID}-modal-footer {
padding: 12px 16px;
border-top: 1px solid #444;
display: flex; justify-content: space-between;
align-items: center;
}
.${APPID}-footer-actions {
display: flex; gap: 8px;
}
.${APPID}-form-group {
margin-bottom: 16px;
}
.${APPID}-form-label {
display: block; margin-bottom: 6px; font-weight: 500; color: #ccc;
}
.${APPID}-form-desc {
font-size: 0.85em; color: #999; margin-bottom: 6px;
}
.${APPID}-form-input {
width: 100%; padding: 6px 8px;
background: #333; border: 1px solid #555; border-radius: 4px;
color: #fff; box-sizing: border-box;
}
.${APPID}-form-input:focus {
border-color: #007bff; outline: none;
}
.${APPID}-checkbox-wrapper {
display: flex; align-items: center; gap: 8px;
}
.${APPID}-btn {
padding: 6px 16px; border-radius: 4px; border: none;
cursor: pointer; font-size: 14px; font-weight: 500;
transition: background 0.2s;
}
.${APPID}-btn-primary {
background: #007bff; color: white;
}
.${APPID}-btn-primary:hover { background: #0056b3; }
.${APPID}-btn-secondary {
background: #555; color: white;
}
.${APPID}-btn-secondary:hover { background: #444; }
/* New Styles for Preview and Warning */
.${APPID}-input-preview-label {
display: block; font-size: 0.85em; color: #888; margin-top: 12px;
}
.${APPID}-input-preview-content {
display: block; font-size: 1.2em; color: #eee; margin-top: 4px; font-family: monospace; word-break: break-all;
transition: color 0.2s;
}
.${APPID}-preview-valid {
color: #4cd964 !important; /* Pastel Green for valid state */
}
.${APPID}-preview-error {
color: #ff6b6b !important; /* Soft Red for error (forbidden chars) */
}
.${APPID}-preview-fallback {
color: #ffb74d !important; /* Soft Orange for fallback state */
}
.${APPID}-text-warning {
display: none; font-size: 0.85em; color: #ffc107; margin-top: 4px;
}
`;
// =================================================================================
// SECTION: Icon Definitions
// =================================================================================
const BASE_ICON_PROPS = {
xmlns: 'http://www.w3.org/2000/svg',
height: '24px',
viewBox: '0 0 24 24',
width: '24px',
fill: '#e3e3e3',
};
const ICONS = {
DOWNLOAD: {
tag: 'svg',
props: BASE_ICON_PROPS,
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{ tag: 'path', props: { d: 'M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z' } },
],
},
SPINNER: {
tag: 'svg',
props: { ...BASE_ICON_PROPS, class: `${APPID}-spinner` },
children: [
{ tag: 'path', props: { d: 'M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18A8,8,0,1,1,20,12,8,8,0,0,1,12,20Z', opacity: '0.3' } },
{ tag: 'path', props: { d: 'M12,2A10,10,0,0,1,22,12h-2A8,8,0,0,0,12,4Z' } },
],
},
SUCCESS: {
tag: 'svg',
props: BASE_ICON_PROPS,
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{ tag: 'path', props: { d: 'M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z' } },
],
},
ERROR: {
tag: 'svg',
props: BASE_ICON_PROPS,
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{ tag: 'path', props: { d: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z' } },
],
},
OPEN_IN_NEW: {
tag: 'svg',
props: BASE_ICON_PROPS,
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{ tag: 'path', props: { d: 'M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z' } },
],
},
PLAY_ARROW: {
tag: 'svg',
props: BASE_ICON_PROPS,
children: [
{ tag: 'path', props: { d: 'M0 0h24v24H0V0z', fill: 'none' } },
{ tag: 'path', props: { d: 'M8 5v14l11-7z' } },
],
},
};
// =================================================================================
// SECTION: Logging Utility
// Description: Centralized logging interface for consistent log output across modules.
// Handles log level control, message formatting, and console API wrapping.
// =================================================================================
class Logger {
/** @property {object} levels - Defines the numerical hierarchy of log levels. */
static levels = {
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
};
/** @property {string} level - The current active log level. */
static level = 'log'; // Default level
/**
* Defines the available badge styles.
* @property {object} styles
*/
static styles = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
RED: 'background: #dc3545;',
YELLOW: 'background: #ffc107; color: black;',
GREEN: 'background: #28a745;',
BLUE: 'background: #007bff;',
GRAY: 'background: #6c757d;',
ORANGE: 'background: #fd7e14;',
PINK: 'background: #e83e8c;',
PURPLE: 'background: #6f42c1;',
CYAN: 'background: #17a2b8; color: black;',
TEAL: 'background: #20c997; color: black;',
};
/**
* Maps log levels to default badge styles.
* @private
*/
static _defaultStyles = {
error: this.styles.RED,
warn: this.styles.YELLOW,
info: this.styles.BLUE,
log: this.styles.GREEN,
debug: this.styles.GRAY,
};
/**
* Sets the current log level.
* @param {string} level The new log level. Must be one of 'error', 'warn', 'info', 'log', 'debug'.
*/
static setLevel(level) {
if (Object.prototype.hasOwnProperty.call(this.levels, level)) {
this.level = level;
} else {
// Use default style (empty string) for the badge
this._out('warn', 'INVALID LEVEL', '', `Invalid log level "${level}". Valid levels are: ${Object.keys(this.levels).join(', ')}. Level not changed.`);
}
}
/**
* Internal method to output logs if the level permits.
* @private
* @param {string} level - The log level ('error', 'warn', 'info', 'log', 'debug').
* @param {string} badgeText - The text inside the badge. If empty, no badge is shown.
* @param {string} badgeStyle - The background-color style (from Logger.styles). If empty, uses default.
* @param {...any} args - The messages to log.
*/
static _out(level, badgeText, badgeStyle, ...args) {
if (this.levels[this.level] >= this.levels[level]) {
const consoleMethod = console[level] || console.log;
if (badgeText !== '') {
// Badge mode: Use %c formatting
let style = badgeStyle;
if (style === '') {
style = this._defaultStyles[level] || this.styles.GRAY;
}
const combinedStyle = `${this.styles.BASE} ${style}`;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
combinedStyle, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
} else {
// No badge mode: Direct output for better object inspection
consoleMethod(LOG_PREFIX, ...args);
}
}
}
/**
* Internal method to start a log group if the level permits (debug or higher).
* @private
* @param {'group'|'groupCollapsed'} method - The console method to use.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static _groupOut(method, badgeText, badgeStyle, ...args) {
if (this.levels[this.level] >= this.levels.debug) {
const consoleMethod = console[method];
if (badgeText !== '') {
let style = badgeStyle;
if (style === '') {
style = this.styles.GRAY;
}
const combinedStyle = `${this.styles.BASE} ${style}`;
consoleMethod(`%c${LOG_PREFIX}%c %c${badgeText}%c`, 'font-weight: bold;', 'color: inherit;', combinedStyle, 'color: inherit;', ...args);
} else {
consoleMethod(LOG_PREFIX, ...args);
}
}
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static error(badgeText, badgeStyle, ...args) {
this._out('error', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static warn(badgeText, badgeStyle, ...args) {
this._out('warn', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static info(badgeText, badgeStyle, ...args) {
this._out('info', badgeText, badgeStyle, ...args);
}
/**
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static log(badgeText, badgeStyle, ...args) {
this._out('log', badgeText, badgeStyle, ...args);
}
/**
* Logs messages for debugging. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args
*/
static debug(badgeText, badgeStyle, ...args) {
this._out('debug', badgeText, badgeStyle, ...args);
}
/**
* Starts a timer for performance measurement. Only active in 'debug' level.
* @param {string} label The label for the timer.
*/
static time(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.time(`${LOG_PREFIX} ${label}`);
}
}
/**
* Ends a timer and logs the elapsed time. Only active in 'debug' level.
* @param {string} label The label for the timer, must match the one used in time().
*/
static timeEnd(label) {
if (this.levels[this.level] >= this.levels.debug) {
console.timeEnd(`${LOG_PREFIX} ${label}`);
}
}
/**
* Starts a log group. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args The title for the log group.
*/
static group(badgeText, badgeStyle, ...args) {
this._groupOut('group', badgeText, badgeStyle, ...args);
}
/**
* Starts a collapsed log group. Only active in 'debug' level.
* @param {string} badgeText
* @param {string} badgeStyle
* @param {...any} args The title for the log group.
*/
static groupCollapsed(badgeText, badgeStyle, ...args) {
this._groupOut('groupCollapsed', badgeText, badgeStyle, ...args);
}
/**
* Closes the current log group. Only active in 'debug' level.
* @returns {void}
*/
static groupEnd() {
if (this.levels[this.level] >= this.levels.debug) {
console.groupEnd();
}
}
}
// Alias for ease of use
const LOG_STYLES = Logger.styles;
// =================================================================================
// SECTION: Execution Guard
// Description: Prevents the script from being executed multiple times per page.
// =================================================================================
class ExecutionGuard {
// A shared key for all scripts from the same author to avoid polluting the window object.
static #GUARD_KEY = `__${OWNERID}_guard__`;
// A specific key for this particular script.
static #APP_KEY = `${APPID}_executed`;
/**
* Checks if the script has already been executed on the page.
* @returns {boolean} True if the script has run, otherwise false.
*/
static hasExecuted() {
return window[this.#GUARD_KEY]?.[this.#APP_KEY] || false;
}
/**
* Sets the flag indicating the script has now been executed.
*/
static setExecuted() {
window[this.#GUARD_KEY] = window[this.#GUARD_KEY] || {};
window[this.#GUARD_KEY][this.#APP_KEY] = true;
}
}
// =================================================================================
// SECTION: General Utilities
// =================================================================================
/**
* @typedef {Node|string|number|boolean|null|undefined} HChild
*/
/**
* Creates a DOM element using a hyperscript-style syntax.
* @param {string} tag - Tag name with optional ID/class (e.g., "div#app.container", "my-element").
* @param {object | HChild | HChild[]} [propsOrChildren] - Attributes object or children.
* @param {HChild | HChild[]} [children] - Children (if props are specified).
* @returns {HTMLElement | SVGElement} The created DOM element.
*/
function h(tag, propsOrChildren, children) {
const SVG_NS = 'http://www.w3.org/2000/svg';
const match = tag.match(/^([a-z0-9-]+)(#[\w-]+)?((\.[\w-]+)*)$/i);
if (!match) throw new Error(`Invalid tag syntax: ${tag}`);
const [, tagName, id, classList] = match;
const isSVG = ['svg', 'circle', 'rect', 'path', 'g', 'line', 'text', 'use', 'defs', 'clipPath'].includes(tagName);
const el = isSVG ? document.createElementNS(SVG_NS, tagName) : document.createElement(tagName);
if (id) el.id = id.slice(1);
if (classList) {
const classes = classList.replace(/\./g, ' ').trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
}
let props = {};
let childrenArray;
if (propsOrChildren && Object.prototype.toString.call(propsOrChildren) === '[object Object]') {
props = propsOrChildren;
childrenArray = children;
} else {
childrenArray = propsOrChildren;
}
// --- Start of Attribute/Property Handling ---
const directProperties = new Set(['value', 'checked', 'selected', 'readOnly', 'disabled', 'multiple', 'textContent']);
const urlAttributes = new Set(['href', 'src', 'action', 'formaction']);
const safeProtocols = new Set(['https:', 'http:', 'mailto:', 'tel:', 'blob:', 'data:']);
for (const [key, value] of Object.entries(props)) {
// 0. Handle `ref` callback (highest priority after props parsing).
if (key === 'ref' && typeof value === 'function') {
value(el);
}
// 1. Security check for URL attributes.
else if (urlAttributes.has(key)) {
const url = String(value);
try {
const parsedUrl = new URL(url); // Throws if not an absolute URL.
if (safeProtocols.has(parsedUrl.protocol)) {
el.setAttribute(key, url);
} else {
el.setAttribute(key, '#');
Logger.warn('UNSAFE URL', LOG_STYLES.YELLOW, `Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.warn('INVALID URL', LOG_STYLES.YELLOW, `Blocked invalid or relative URL in attribute "${key}":`, url);
}
}
// 2. Direct property assignments.
else if (directProperties.has(key)) {
el[key] = value;
}
// 3. Other specialized handlers.
else if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (key === 'dataset' && typeof value === 'object') {
for (const [dataKey, dataVal] of Object.entries(value)) {
el.dataset[dataKey] = dataVal;
}
} else if (key.startsWith('on')) {
if (typeof value === 'function') {
el.addEventListener(key.slice(2).toLowerCase(), value);
}
} else if (key === 'className') {
const classes = String(value).trim();
if (classes) {
el.classList.add(...classes.split(/\s+/));
}
} else if (key.startsWith('aria-')) {
el.setAttribute(key, String(value));
}
// 4. Default attribute handling.
else if (value !== false && value !== null && typeof value !== 'undefined') {
el.setAttribute(key, value === true ? '' : String(value));
}
}
// --- End of Attribute/Property Handling ---
const fragment = document.createDocumentFragment();
/**
* Appends a child node or text to the document fragment.
* @param {HChild} child - The child to append.
*/
function append(child) {
if (child === null || child === false || typeof child === 'undefined') return;
if (typeof child === 'string' || typeof child === 'number') {
fragment.appendChild(document.createTextNode(String(child)));
} else if (Array.isArray(child)) {
child.forEach(append);
} else if (child instanceof Node) {
fragment.appendChild(child);
} else {
throw new Error('Unsupported child type');
}
}
append(childrenArray);
el.appendChild(fragment);
if (el instanceof HTMLElement || el instanceof SVGElement) {
return el;
}
throw new Error('Created element is not a valid HTMLElement or SVGElement');
}
/**
* Recursively builds a DOM element from a definition object using the h() function.
* @param {object} def The definition object for the element.
* @returns {HTMLElement | SVGElement | null} The created DOM element.
*/
function createIconFromDef(def) {
if (!def) return null;
const children = def.children ? def.children.map((child) => createIconFromDef(child)) : [];
return h(def.tag, def.props, children);
}
const CACHED_ICONS = (() => {
const cache = {};
for (const key in ICONS) {
cache[key] = createIconFromDef(ICONS[key]);
}
return cache;
})();
/**
* Helper function to check if an item is a non-array object.
* @param {unknown} item The item to check.
* @returns {item is Record<string, any>}
*/
function isObject(item) {
return !!(item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Creates a deep copy of a JSON-serializable object.
* @template T
* @param {T} obj The object to clone.
* @returns {T} The deep copy of the object.
*/
function deepClone(obj) {
try {
return structuredClone(obj);
} catch (e) {
Logger.error('CLONE FAILED', '', 'deepClone failed. Data contains non-clonable items.', e);
throw e;
}
}
/**
* Recursively resolves the configuration by overlaying source properties onto the target object.
* The target object is mutated. This handles recursive updates for nested objects but overwrites arrays/primitives.
*
* [MERGE BEHAVIOR]
* Keys present in 'source' but missing in 'target' are ignored.
* The 'target' object acts as a schema; it must contain all valid keys.
*
* @param {object} target The target object (e.g., a deep copy of default config).
* @param {object} source The source object (e.g., user config).
* @returns {object} The mutated target object.
*/
function resolveConfig(target, source) {
for (const key in source) {
// Security: Prevent prototype pollution
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
if (Object.prototype.hasOwnProperty.call(source, key)) {
// Strict check: Ignore keys that do not exist in the target (default config).
if (!Object.prototype.hasOwnProperty.call(target, key)) {
continue;
}
const sourceVal = source[key];
const targetVal = target[key];
if (isObject(sourceVal) && isObject(targetVal)) {
// If both are objects, recurse
resolveConfig(targetVal, sourceVal);
} else if (typeof sourceVal !== 'undefined') {
// Otherwise, overwrite or set the value from the source
target[key] = sourceVal;
}
}
}
return target;
}
/**
* Formats a UNIX timestamp into a YYYYMMDD_HHMMSS string.
* @param {number} timestamp - The UNIX timestamp (in seconds).
* @returns {string} The formatted date string.
*/
function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000); // Convert seconds to milliseconds
const Y = date.getFullYear();
const M = String(date.getMonth() + 1).padStart(2, '0');
const D = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const m = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${Y}${M}${D}_${h}${m}${s}`;
}
/**
* Safely extracts the file extension (including the dot) from a URL.
* Considers query parameters and hashes.
* @param {string} url - The URL to parse.
* @returns {string} The extension (e.g., ".mp4") or an empty string if not found.
*/
function getExtension(url) {
try {
// Use URL constructor to isolate pathname, ignoring query/hash
const pathname = new URL(url).pathname;
// Find the last dot
const lastDotIndex = pathname.lastIndexOf('.');
// If no dot is found, or if it's the first character (e.g., "/.config"),
// it's not a valid extension for our purpose.
if (lastDotIndex < 1) {
return ''; // No extension found
}
// Return the substring from the last dot to the end.
return pathname.substring(lastDotIndex); // e.g., ".mp4"
} catch (e) {
Logger.warn('URL ERROR', LOG_STYLES.YELLOW, 'Could not parse URL to get extension:', url, e);
return ''; // Return empty on URL parsing failure
}
}
/**
* Resolves a filename template with provided replacements, handling sanitization.
* @param {string} template - The filename template (e.g., "{user}_{date}_{id}").
* @param {Object<string, string>} replacements - Key-value pairs for replacements.
* @returns {string} The resolved and sanitized filename.
*/
function resolveFilename(template, replacements) {
// 1. Validate bracket checks (simple balance check for {})
const openCount = (template.match(/\{/g) || []).length;
const closeCount = (template.match(/\}/g) || []).length;
let validTemplate = template;
// Fallback to default if brackets are unbalanced
if (openCount !== closeCount) {
Logger.warn('TEMPLATE', LOG_STYLES.YELLOW, 'Filename template has unbalanced brackets. Falling back to default.');
validTemplate = '{user}_{date}_{id}';
}
// 2. Replace placeholders
let result = validTemplate.replace(/\{(\w+)\}/g, (match, key) => {
// If key exists in replacements, use it (even if empty string).
// If key does NOT exist in replacements, keep the original placeholder string.
if (Object.prototype.hasOwnProperty.call(replacements, key)) {
const val = replacements[key];
return val ? String(val) : '';
}
return match;
});
// 3. Sanitize
// Step A: Remove forbidden characters (Windows/Linux/macOS safe)
// Removes only OS reserved characters: < > : " / \ | ? *
result = result.replace(/[<>:"/\\|?*]/g, '');
// Step B: Replace consecutive separators (space, underscore, hyphen, dot) with a single instance of the first match.
// This prevents double underscores like "__" or "_-_" when a placeholder is empty.
result = result.replace(/([ _.-])\1+/g, '$1');
// Remove separators from start and end
result = result.replace(/^[ _.-]+|[ _.-]+$/g, '');
// Fallback if result becomes empty (unlikely but possible if all data is missing)
if (!result) {
result = 'video';
}
return result;
}
// =================================================================================
// SECTION: Event-Driven Architecture (Pub/Sub)
// Description: A event bus for decoupled communication between classes.
// =================================================================================
const EventBus = {
events: {},
uiWorkQueue: [],
isUiWorkScheduled: false,
_logAggregation: {},
// prettier-ignore
_aggregatedEvents: new Set([
]),
_aggregationDelay: 500, // ms
/**
* Subscribes a listener to an event using a unique key.
* If a subscription with the same event and key already exists, it will be overwritten.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription (e.g., 'ClassName.methodName').
*/
subscribe(event, listener, key) {
if (!key) {
Logger.error('', '', 'EventBus.subscribe requires a unique key.');
return;
}
if (!this.events[event]) {
this.events[event] = new Map();
}
this.events[event].set(key, listener);
},
/**
* Subscribes a listener that will be automatically unsubscribed after one execution.
* @param {string} event The event name.
* @param {Function} listener The callback function.
* @param {string} key A unique key for this subscription.
*/
once(event, listener, key) {
if (!key) {
Logger.error('', '', 'EventBus.once requires a unique key.');
return;
}
const onceListener = (...args) => {
this.unsubscribe(event, key);
listener(...args);
};
this.subscribe(event, onceListener, key);
},
/**
* Unsubscribes a listener from an event using its unique key.
* @param {string} event The event name.
* @param {string} key The unique key used during subscription.
*/
unsubscribe(event, key) {
if (!this.events[event] || !key) {
return;
}
this.events[event].delete(key);
if (this.events[event].size === 0) {
delete this.events[event];
}
},
/**
* Publishes an event, calling all subscribed listeners with the provided data.
* @param {string} event The event name.
* @param {...any} args The data to pass to the listeners.
*/
publish(event, ...args) {
if (!this.events[event]) {
return;
}
if (Logger.levels[Logger.level] >= Logger.levels.debug) {
// --- Aggregation logic START ---
if (this._aggregatedEvents.has(event)) {
if (!this._logAggregation[event]) {
this._logAggregation[event] = { timer: null, count: 0 };
}
const aggregation = this._logAggregation[event];
aggregation.count++;
clearTimeout(aggregation.timer);
aggregation.timer = setTimeout(() => {
const finalCount = this._logAggregation[event]?.count || 0;
if (finalCount > 0) {
Logger.debug('EventBus', LOG_STYLES.PURPLE, `Event Published: ${event} (x${finalCount})`);
}
delete this._logAggregation[event];
}, this._aggregationDelay);
// Execute subscribers for the aggregated event, but without the verbose individual logs.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.error('', '', `EventBus error in listener for event "${event}":`, e);
}
});
return; // End execution here for aggregated events in debug mode.
}
// --- Aggregation logic END ---
// In debug mode, provide detailed logging for NON-aggregated events.
const subscriberKeys = [...this.events[event].keys()];
Logger.groupCollapsed('EventBus', LOG_STYLES.PURPLE, `Event Published: ${event}`);
if (args.length > 0) {
console.log(' - Payload:', ...args);
} else {
console.log(' - Payload: (No data)');
}
// Displaying subscribers helps in understanding the event's impact.
if (subscriberKeys.length > 0) {
console.log(' - Subscribers:\n' + subscriberKeys.map((key) => ` > ${key}`).join('\n'));
} else {
console.log(' - Subscribers: (None)');
}
// Iterate with keys for better logging
this.events[event].forEach((listener, key) => {
try {
// Log which specific subscriber is being executed
Logger.debug('', '', `-> Executing: ${key}`);
listener(...args);
} catch (e) {
// Enhance error logging with the specific subscriber key
Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener "${key}" failed for event "${event}":`, e);
}
});
Logger.groupEnd();
} else {
// Iterate over a copy of the values in case a listener unsubscribes itself.
[...this.events[event].values()].forEach((listener) => {
try {
listener(...args);
} catch (e) {
Logger.error('LISTENER ERROR', LOG_STYLES.RED, `Listener failed for event "${event}":`, e);
}
});
}
},
/**
* Queues a function to be executed on the next animation frame.
* Batches multiple UI updates into a single repaint cycle.
* @param {Function} workFunction The function to execute.
*/
queueUIWork(workFunction) {
this.uiWorkQueue.push(workFunction);
if (!this.isUiWorkScheduled) {
this.isUiWorkScheduled = true;
requestAnimationFrame(this._processUIWorkQueue.bind(this));
}
},
/**
* @private
* Processes all functions in the UI work queue.
*/
_processUIWorkQueue() {
// Prevent modifications to the queue while processing.
const queueToProcess = [...this.uiWorkQueue];
this.uiWorkQueue.length = 0;
for (const work of queueToProcess) {
try {
work();
} catch (e) {
Logger.error('UI QUEUE ERROR', LOG_STYLES.RED, 'Error in queued UI work:', e);
}
}
this.isUiWorkScheduled = false;
},
};
/**
* Creates a unique, consistent event subscription key for EventBus.
* @param {object} context The `this` context of the subscribing class instance.
* @param {string} eventName The full event name from the EVENTS constant.
* @returns {string} A key in the format 'ClassName.purpose'.
*/
function createEventKey(context, eventName) {
// Extract a meaningful 'purpose' from the event name
const parts = eventName.split(':');
const purpose = parts.length > 1 ? parts.slice(1).join('_') : parts[0];
let contextName = 'UnknownContext';
if (context && context.constructor && context.constructor.name) {
contextName = context.constructor.name;
}
return `${contextName}.${purpose}`;
}
// =================================================================================
// SECTION: Configuration Management
// =================================================================================
const ConfigProcessor = {
/**
* Processes and sanitizes an entire configuration object.
* @param {object|null} userConfig The user configuration object (partial or full).
* @returns {object} The complete, sanitized configuration object.
*/
process(userConfig) {
// 1. Start with a deep copy of the defaults.
const completeConfig = deepClone(DEFAULT_CONFIG);
if (userConfig) {
// 2. Merge user config
resolveConfig(completeConfig, userConfig);
}
return completeConfig;
},
};
class ConfigManager {
constructor() {
/** @type {object|null} */
this.config = null;
}
/**
* Loads the configuration from storage asynchronously.
* Assumes the configuration is stored as a JSON string.
* @returns {Promise<void>}
*/
async load() {
const raw = await GM.getValue(CONSTANTS.CONFIG_KEY, null);
let userConfig = null;
if (raw) {
try {
userConfig = JSON.parse(raw);
} catch (e) {
Logger.error('CONFIG LOAD', LOG_STYLES.RED, 'Failed to parse configuration. Using default settings.', e);
}
}
this.config = ConfigProcessor.process(userConfig);
// Apply logger level immediately
Logger.setLevel(this.config.developer.logger_level);
}
/**
* Saves the configuration object to storage as a JSON string.
* @param {object} newConfig The configuration object to save.
* @returns {Promise<void>}
*/
async save(newConfig) {
const completeConfig = ConfigProcessor.process(newConfig);
await GM.setValue(CONSTANTS.CONFIG_KEY, JSON.stringify(completeConfig));
this.config = completeConfig;
// Apply new settings
Logger.setLevel(this.config.developer.logger_level);
// Notify other components
EventBus.publish(EVENTS.CONFIG_UPDATED, this.config);
EventBus.publish(EVENTS.CONFIG_SAVE_SUCCESS);
}
/**
* @returns {object} The current configuration object.
*/
get() {
return this.config || deepClone(DEFAULT_CONFIG);
}
}
// =================================================================================
// SECTION: Settings Modal
// =================================================================================
class SettingsModal {
/**
* @param {ConfigManager} configManager
*/
constructor(configManager) {
this.configManager = configManager;
this.overlay = null;
// Bind the keydown handler once to ensure consistent reference for add/removeEventListener
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
}
/**
* Opens the settings modal.
*/
open() {
if (this.overlay) return;
const config = this.configManager.get();
// Create input elements
const filenameInput = h(`input#${APPID}-input-filename.${APPID}-form-input`, {
type: 'text',
value: config.download.filenameTemplate,
});
const warningText = h(`div#${APPID}-warning-text.${APPID}-text-warning`, 'Forbidden characters will be removed.');
const previewLabel = h(`div.${APPID}-input-preview-label`, 'Preview:');
const previewContent = h(`div#${APPID}-preview-content.${APPID}-input-preview-content`, '');
// Attach input listener for real-time preview
if (filenameInput instanceof HTMLInputElement) {
filenameInput.addEventListener('input', () => {
this._updatePreview(filenameInput.value, warningText, previewContent);
});
}
// Viewer Type Radio Buttons
const viewerTypeContainer = h('div', { style: { display: 'flex', gap: '16px', marginTop: '8px' } }, [
h(`label.${APPID}-checkbox-wrapper`, [
h(`input#${APPID}-input-viewertype-default`, {
type: 'radio',
name: 'viewerType',
value: 'default',
checked: config.openInNewTab.viewerType === 'default',
}),
h('span', 'Default (RedGIFs Page)'),
]),
h(`label.${APPID}-checkbox-wrapper`, [
h(`input#${APPID}-input-viewertype-clean`, {
type: 'radio',
name: 'viewerType',
value: 'clean',
checked: config.openInNewTab.viewerType === 'clean',
}),
h('span', 'Clean (Video Only)'),
]),
]);
this.overlay = h(
`div.${APPID}-modal-overlay`,
{
// Click handler for closing when clicking outside (on the overlay)
onclick: (e) => {
if (e.target === this.overlay) this.close();
},
},
[
h(`div.${APPID}-modal-box`, [
// Header
h(`div.${APPID}-modal-header`, [h('span', `${APPNAME} Settings`)]),
// Content
h(`div.${APPID}-modal-content`, [
this._createFormGroup(
'Filename Template',
null, // Desc is moved inside the control wrapper
// Wrap input and feedback elements together
h('div', [h(`div.${APPID}-form-desc`, 'Available placeholders: {user}, {date}, {id}'), filenameInput, warningText, previewLabel, previewContent])
),
this._createFormGroup(
'Appearance',
'',
h(`label.${APPID}-checkbox-wrapper`, [
h(`input#${APPID}-input-hover`, {
type: 'checkbox',
checked: config.common.showOnlyOnHover,
}),
h('span', 'Show buttons only on hover (Desktop)'),
])
),
this._createFormGroup(
'Functionality',
'',
h('div', [
h(`label.${APPID}-checkbox-wrapper`, [
h(`input#${APPID}-input-newtab`, {
type: 'checkbox',
checked: config.openInNewTab.enabled,
}),
h('span', 'Enable "Open in New Tab" button'),
]),
h(`div.${APPID}-form-desc`, { style: { marginTop: '12px' } }, 'Viewer Type:'),
viewerTypeContainer,
])
),
]),
// Footer
h(`div.${APPID}-modal-footer`, [
// Left: Restore Defaults
h(`button.${APPID}-btn.${APPID}-btn-secondary`, { onclick: () => this._restoreDefaults() }, 'Restore Defaults'),
// Right: Actions
h(`div.${APPID}-footer-actions`, [
h(`button.${APPID}-btn.${APPID}-btn-secondary`, { onclick: () => this.close() }, 'Cancel'),
h(`button.${APPID}-btn.${APPID}-btn-primary`, { onclick: () => this.save() }, 'Save'),
]),
]),
]),
]
);
document.body.appendChild(this.overlay);
// Add global key listener for ESC
document.addEventListener('keydown', this._boundHandleKeyDown);
// Trigger initial preview
if (filenameInput instanceof HTMLInputElement) {
this._updatePreview(filenameInput.value, warningText, previewContent);
// Set initial focus
filenameInput.focus();
}
}
/**
* Closes the settings modal.
*/
close() {
if (this.overlay) {
// Remove global key listener
document.removeEventListener('keydown', this._boundHandleKeyDown);
this.overlay.remove();
this.overlay = null;
}
}
/**
* Saves the current settings from the form.
*/
async save() {
const newConfig = this.configManager.get();
// Collect values from DOM
const filenameInput = document.getElementById(`${APPID}-input-filename`);
const hoverInput = document.getElementById(`${APPID}-input-hover`);
const newTabInput = document.getElementById(`${APPID}-input-newtab`);
const viewerTypeCleanInput = document.getElementById(`${APPID}-input-viewertype-clean`);
if (filenameInput instanceof HTMLInputElement) newConfig.download.filenameTemplate = filenameInput.value;
if (hoverInput instanceof HTMLInputElement) newConfig.common.showOnlyOnHover = hoverInput.checked;
if (newTabInput instanceof HTMLInputElement) newConfig.openInNewTab.enabled = newTabInput.checked;
// Radio button logic
if (viewerTypeCleanInput instanceof HTMLInputElement) {
newConfig.openInNewTab.viewerType = viewerTypeCleanInput.checked ? 'clean' : 'default';
}
await this.configManager.save(newConfig);
this.close();
}
/**
* Updates the preview text and warning based on the input template.
* @private
*/
_updatePreview(template, warningEl, previewEl) {
const dummyReplacements = {
user: 'RedGifsOfficial',
date: '20250101_120000',
id: 'watchfulwaiting',
};
// Resolve filename using dummy data
const resolved = resolveFilename(template, dummyReplacements);
// Append example extension
const previewFilename = `${resolved}.mp4`;
// Reset classes and state
previewEl.classList.remove(`${APPID}-preview-valid`, `${APPID}-preview-error`, `${APPID}-preview-fallback`);
warningEl.style.display = 'none';
warningEl.textContent = '';
// 1. Check for fallback triggers (Unbalanced brackets or Empty)
// Note: resolveFilename handles this internally, but we check here to provide UI feedback.
const openCount = (template.match(/\{/g) || []).length;
const closeCount = (template.match(/\}/g) || []).length;
const isUnbalanced = openCount !== closeCount;
const isEmpty = !template || template.trim().length === 0;
if (isUnbalanced || isEmpty) {
previewEl.classList.add(`${APPID}-preview-fallback`);
warningEl.style.display = 'block';
if (isEmpty) {
warningEl.textContent = "Template is empty. Using 'video' as fallback.";
} else {
warningEl.textContent = 'Unbalanced brackets. Reverted to default.';
}
previewEl.textContent = previewFilename;
return;
}
// 2. Check for forbidden characters
const forbiddenRegex = /[<>:"/\\|?*]/;
const hasForbidden = forbiddenRegex.test(template);
if (hasForbidden) {
previewEl.classList.add(`${APPID}-preview-error`);
warningEl.style.display = 'block';
warningEl.textContent = 'Forbidden characters (< > : " / \\ | ? *) will be removed.';
previewEl.textContent = previewFilename;
return;
}
// 3. Valid State
previewEl.classList.add(`${APPID}-preview-valid`);
previewEl.textContent = previewFilename;
}
/**
* Restores default settings to the form inputs.
* @private
*/
_restoreDefaults() {
// Restore Filename Template
const filenameInput = document.getElementById(`${APPID}-input-filename`);
if (filenameInput instanceof HTMLInputElement) {
filenameInput.value = DEFAULT_CONFIG.download.filenameTemplate;
// Trigger preview update manually since programmatic change doesn't fire 'input' event
const warningText = document.getElementById(`${APPID}-warning-text`);
const previewContent = document.getElementById(`${APPID}-preview-content`);
if (warningText && previewContent) {
this._updatePreview(filenameInput.value, warningText, previewContent);
}
}
// Restore Checkboxes
const hoverInput = document.getElementById(`${APPID}-input-hover`);
if (hoverInput instanceof HTMLInputElement) {
hoverInput.checked = DEFAULT_CONFIG.common.showOnlyOnHover;
}
const newTabInput = document.getElementById(`${APPID}-input-newtab`);
if (newTabInput instanceof HTMLInputElement) {
newTabInput.checked = DEFAULT_CONFIG.openInNewTab.enabled;
}
// Restore Viewer Type Radio Buttons
const defaultType = DEFAULT_CONFIG.openInNewTab.viewerType;
const defaultRadio = document.getElementById(`${APPID}-input-viewertype-default`);
const cleanRadio = document.getElementById(`${APPID}-input-viewertype-clean`);
if (defaultRadio instanceof HTMLInputElement && cleanRadio instanceof HTMLInputElement) {
if (defaultType === 'clean') {
cleanRadio.checked = true;
} else {
defaultRadio.checked = true;
}
}
}
/**
* Handles global keydown events.
* @private
*/
_handleKeyDown(e) {
if (e.key === 'Escape') {
this.close();
}
}
/**
* Helper to create a labeled form group.
* @private
*/
_createFormGroup(label, desc, control) {
return h(`div.${APPID}-form-group`, [h(`label.${APPID}-form-label`, label), control, desc ? h(`div.${APPID}-form-desc`, desc) : null]);
}
}
// =================================================================================
// SECTION: API Manager
// =================================================================================
class ApiManager {
/**
* Extracts the video ID from a 'gif' object in the API response.
* @param {object} gif - The gif object from the API.
* @returns {string|undefined} The video ID.
*/
static #API_GIF_ID_EXTRACTOR = (gif) => gif?.id;
/**
* Extracts the HD video URL from a 'gif' object in the API response.
* @param {object} gif - The gif object from the API.
* @returns {string|undefined} The HD URL.
*/
static #API_GIF_HD_URL_EXTRACTOR = (gif) => gif?.urls?.hd;
/**
* Extracts the User Name from a 'gif' object in the API response.
* @param {object} gif - The gif object from the API.
* @returns {string|undefined} The User Name.
*/
static #API_GIF_USERNAME_EXTRACTOR = (gif) => gif?.userName;
/**
* Extracts the Create Date (timestamp) from a 'gif' object in the API response.
* @param {object} gif - The gif object from the API.
* @returns {number|undefined} The creation timestamp.
*/
static #API_GIF_CREATEDATE_EXTRACTOR = (gif) => gif?.createDate;
constructor() {
/** @type {Map<string, {hdUrl: string, userName: string, createDate: number}>} */
this.videoCache = new Map();
this._initJsonInterceptor();
}
/**
* Gets the cached Video Info for a given video ID.
* @param {string} videoId The ID of the video.
* @returns {{hdUrl: string, userName: string, createDate: number}|undefined} The cached info object or undefined if not found.
*/
getCachedVideoInfo(videoId) {
// Normalize ID to lowercase for cache lookup
return this.videoCache.get(videoId.toLowerCase());
}
/**
* Sets up interceptors for JSON.parse and Response.prototype.json to capture API responses.
* Uses unsafeWindow to ensure access to the page's context.
* @private
*/
_initJsonInterceptor() {
const globalScope = unsafeWindow;
const self = this;
// 1. Hook JSON.parse
// Captures traditional JSON parsing (e.g. from XHR or text-based fetch)
const originalJsonParse = globalScope.JSON.parse;
globalScope.JSON.parse = function (text, reviver) {
// Execute original function first. If this fails, let it throw naturally.
const result = originalJsonParse.call(this, text, reviver);
// Safely intercept the result without affecting the site's flow
try {
self._processApiData(result);
} catch (e) {
// Silent fail to ensure site functionality is never broken
Logger.error('INTERCEPT', LOG_STYLES.RED, 'JSON intercept error:', e);
}
return result;
};
// 2. Hook Response.prototype.json
// Captures modern fetch() API calls that use .json() directly
if (globalScope.Response && globalScope.Response.prototype) {
const originalResponseJson = globalScope.Response.prototype.json;
globalScope.Response.prototype.json = async function () {
// Execute original function first. If promise rejects, propagate it.
const result = await originalResponseJson.call(this);
// Safely intercept the result
try {
self._processApiData(result);
} catch (e) {
// Silent fail
Logger.error('INTERCEPT', LOG_STYLES.RED, 'Response intercept error:', e);
}
return result;
};
}
}
/**
* Processes the parsed API data to cache video URLs.
* @param {object} data The parsed JSON data from the API.
* @private
*/
_processApiData(data) {
try {
// Handle both single 'gif' object (watch page) and 'gifs' array (feeds)
const gifsToProcess = [];
if (data && Array.isArray(data.gifs)) {
gifsToProcess.push(...data.gifs);
}
// Also check for the single 'gif' object
if (data && data.gif && typeof data.gif === 'object') {
gifsToProcess.push(data.gif);
}
// Check if we have any gifs to process
if (gifsToProcess.length > 0) {
let count = 0;
for (const gif of gifsToProcess) {
// Use internal static extractors
// Extractors are null-safe (e.g., gif?.id)
const videoId = ApiManager.#API_GIF_ID_EXTRACTOR(gif);
const hdUrl = ApiManager.#API_GIF_HD_URL_EXTRACTOR(gif);
// Only require videoId and hdUrl to cache.
if (videoId && hdUrl) {
// Normalize ID to lowercase for cache storage to match HTML attributes
const normalizedId = videoId.toLowerCase();
if (!this.videoCache.has(normalizedId)) {
// Get optional metadata. These can be undefined.
const userName = ApiManager.#API_GIF_USERNAME_EXTRACTOR(gif);
const createDate = ApiManager.#API_GIF_CREATEDATE_EXTRACTOR(gif);
// Store the info object, possibly with undefined values.
this.videoCache.set(normalizedId, { hdUrl, userName, createDate });
count++;
}
}
}
// Log on successful processing
const path = '[JSON_PARSE]';
if (count > 0) {
Logger.log('CACHE UPDATED', LOG_STYLES.TEAL, `${path} Added ${count} new items. Total: ${this.videoCache.size}`);
} else {
// Log if the feed contained items, but all were already cached.
Logger.log('API HIT', LOG_STYLES.TEAL, `${path} (No new items added. Cache total: ${this.videoCache.size})`);
}
}
// If no gifs found (e.g., an empty feed or non-media API response), silently do nothing.
} catch (error) {
Logger.warn('API ERROR', LOG_STYLES.YELLOW, 'Failed to process API data object:', error, data);
}
}
}
// =================================================================================
// SECTION: UI Manager
// =================================================================================
class UIManager {
/**
* @param {ConfigManager} configManager
*/
constructor(configManager) {
this.subscriptions = [];
/** @type {ConfigManager} */
this.configManager = configManager;
/** @type {HTMLElement|null} */
this.toastContainer = null;
// Subscribe to config updates to refresh styles dynamically
this._subscribe(EVENTS.CONFIG_UPDATED, () => this.updateDynamicStyles());
}
/**
* Helper to subscribe to EventBus events with automatic key management.
* @param {string} event - The event name to subscribe to.
* @param {Function} listener - The callback function.
* @private
*/
_subscribe(event, listener) {
const key = createEventKey(this, event);
EventBus.subscribe(event, listener.bind(this), key);
this.subscriptions.push({ event, key });
}
/**
* Initializes the UI components that require the DOM.
* Creates and appends the toast container to the document body.
*/
init() {
this._createToastContainer();
this.injectStaticStyles();
this.updateDynamicStyles();
}
/**
* Injects the static CSS styles (UI templates + Modal styles).
* These do not change during the session.
*/
injectStaticStyles() {
const id = `${APPID}-static-styles`;
if (document.getElementById(id)) return;
const styleElement = h('style', { id: id, type: 'text/css', 'data-owner': APPID }, UI_STYLES_TEMPLATE + MODAL_STYLES);
document.head.appendChild(styleElement);
}
/**
* Updates CSS styles that depend on configuration (e.g., showOnlyOnHover).
* Called on init and when configuration changes.
*/
updateDynamicStyles() {
const config = this.configManager.get();
const id = `${APPID}-dynamic-styles`;
// Remove existing dynamic styles to re-apply
let styleEl = document.getElementById(id);
if (!styleEl) {
const newStyleEl = h('style', { id: id, type: 'text/css' });
if (newStyleEl instanceof HTMLElement) {
styleEl = newStyleEl;
document.head.appendChild(styleEl);
}
}
if (!styleEl) return;
// Define class names locally
const CLS = {
TILE_OPEN: `${APPID}-open-in-new-tab-btn`,
TILE_DOWNLOAD: `${APPID}-tile-download-btn`,
PREVIEW_OPEN: `${APPID}-preview-open-btn`,
PREVIEW_DOWNLOAD: `${APPID}-preview-download-btn`,
};
let css = '';
// Apply 'Show Only On Hover' logic
if (config.common.showOnlyOnHover) {
const createHoverStyle = (btnClass, parentSelector) => `
/* Default state: Hidden and non-clickable */
.${btnClass} {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out;
}
/* Hover state: Visible and clickable */
${parentSelector}:hover .${btnClass} {
opacity: 1;
pointer-events: auto;
}
/* Mobile: Always visible (force override) */
.App.phone .${btnClass} {
opacity: 1 !important;
pointer-events: auto !important;
}
`;
// Apply to all 4 button types
css += createHoverStyle(CLS.TILE_OPEN, CONSTANTS.TILE_ITEM_SELECTOR);
css += createHoverStyle(CLS.PREVIEW_OPEN, CONSTANTS.VIDEO_CONTAINER_SELECTOR);
css += createHoverStyle(CLS.TILE_DOWNLOAD, CONSTANTS.TILE_ITEM_SELECTOR);
css += createHoverStyle(CLS.PREVIEW_DOWNLOAD, CONSTANTS.VIDEO_CONTAINER_SELECTOR);
}
// Layout Adjustments (if Open in New Tab is disabled)
if (!config.openInNewTab.enabled) {
css += `
/* Move Download Button to top position */
.${CLS.TILE_DOWNLOAD} { top: 8px !important; }
.${CLS.PREVIEW_DOWNLOAD} { top: 8px !important; }
/* Mobile adjustment for Preview */
.App.phone .${CLS.PREVIEW_DOWNLOAD} { top: 64px !important; }
/* Hide existing buttons if they exist in DOM (for immediate update without reload) */
.${CLS.TILE_OPEN}, .${CLS.PREVIEW_OPEN} { display: none !important; }
`;
}
styleEl.textContent = css;
}
/**
* Updates the visual state (icon and title) of all buttons matching the selector.
* @param {string} selector - The CSS selector for the buttons.
* @param {keyof ICONS} iconName - The new icon name.
* @param {string} title - The new tooltip title.
*/
updateButtonVisuals(selector, iconName, title) {
const buttons = document.querySelectorAll(selector);
buttons.forEach((btn) => {
if (!(btn instanceof HTMLElement)) return;
// Update Title
btn.title = title;
// Update Icon
this._setButtonIcon(btn, iconName);
});
}
/**
* Creates and appends the toast container to the document body.
* @private
*/
_createToastContainer() {
const container = h(`div.${APPID}-toast-container`);
if (container instanceof HTMLElement) {
this.toastContainer = container;
document.body.appendChild(this.toastContainer);
}
}
/**
* Displays a toast notification.
* @param {string} message The message to display.
* @param {'info'|'success'|'error'} type The type of toast.
*/
showToast(message, type) {
if (!this.toastContainer) {
Logger.error('UI ERROR', LOG_STYLES.RED, 'Toast container element not found. Cannot display toast.');
return;
}
const toastClass = `${APPID}-toast-${type}`;
const toastElement = h(`div.${APPID}-toast`, { className: toastClass }, message);
this.toastContainer.appendChild(toastElement);
// Determine duration based on type
const duration = type === 'error' ? CONSTANTS.TOAST_ERROR_DURATION : CONSTANTS.TOAST_DURATION;
// Start the process to remove the toast after a delay.
setTimeout(() => {
toastElement.classList.add('exiting');
// Set a second, final timeout to remove the element from the DOM.
setTimeout(() => {
toastElement.remove();
}, CONSTANTS.TOAST_FADE_OUT_DURATION);
}, duration);
}
/**
* A generic helper to create and append a button.
* @param {object} options - The configuration for the button.
* @param {HTMLElement} options.parentElement - The element to append the button to.
* @param {string} options.className - The CSS class for the button.
* @param {string} options.title - The button's tooltip text.
* @param {string} options.iconName - The key of the icon in the ICONS object.
* @param {(e: MouseEvent) => void} options.clickHandler - The function to call on click.
*/
createButton({ parentElement, className, title, iconName, clickHandler }) {
// Prevent duplicate buttons
if (parentElement.querySelector(`.${className}`)) {
return;
}
const button = h(`button.${className}`, {
title: title,
onclick: clickHandler,
});
if (button instanceof HTMLElement) {
this._setButtonIcon(button, iconName);
parentElement.appendChild(button);
}
}
/**
* Sets the icon for a given button.
* @param {HTMLElement} button The button or anchor element to modify.
* @param {string} iconName The name of the icon to set.
* @private
*/
_setButtonIcon(button, iconName) {
const cachedIcon = CACHED_ICONS[iconName];
if (!cachedIcon) {
Logger.error('ICON ERROR', LOG_STYLES.RED, `Icon "${iconName}" not found.`);
return;
}
// Clear existing content
while (button.firstChild) {
button.removeChild(button.firstChild);
}
// Add new icon
const newIcon = cachedIcon.cloneNode(true);
if (newIcon) {
button.appendChild(newIcon);
}
}
/**
* Updates the button's visual state and reverts it after a delay for transient states.
* @param {HTMLButtonElement} button The button to update.
* @param {'IDLE'|'LOADING_LOCKED'|'LOADING_CANCELLABLE'|'SUCCESS'|'ERROR'} state The new state.
*/
updateButtonState(button, state) {
const stateMap = {
IDLE: { icon: 'DOWNLOAD', disabled: false },
LOADING_LOCKED: { icon: 'SPINNER', disabled: true }, // Cancel lock
LOADING_CANCELLABLE: { icon: 'SPINNER', disabled: false }, // Cancellable
SUCCESS: { icon: 'SUCCESS', disabled: true },
ERROR: { icon: 'ERROR', disabled: true },
};
const { icon, disabled } = stateMap[state] || stateMap.IDLE;
this._setButtonIcon(button, icon);
button.disabled = disabled;
// Revert to IDLE state after a delay for success or error states.
if (state === 'SUCCESS' || state === 'ERROR') {
setTimeout(() => {
this.updateButtonState(button, 'IDLE');
}, CONSTANTS.ICON_REVERT_DELAY);
}
}
/**
* A generic helper to create and append a link button (<a> tag).
* @param {object} options - The configuration for the button.
* @param {HTMLElement} options.parentElement - The element to append the button to.
* @param {string} options.className - The CSS class for the button.
* @param {string} options.title - The button's tooltip text.
* @param {string} options.iconName - The key of the icon in the ICONS object.
* @param {string} options.href - The URL the link points to.
* @param {(e: MouseEvent) => void} [options.clickHandler] - Optional click handler (e.g., for stopPropagation).
*/
createLinkButton({ parentElement, className, title, iconName, href, clickHandler }) {
// Prevent duplicate buttons
if (parentElement.querySelector(`.${className}`)) {
return;
}
const button = h(`a.${className}`, {
href: href,
target: '_blank',
rel: 'noopener noreferrer',
title: title,
draggable: 'false', // Prevent dragging the link image
onclick: clickHandler,
});
if (button instanceof HTMLElement) {
this._setButtonIcon(button, iconName);
parentElement.appendChild(button);
}
}
}
// =================================================================================
// SECTION: Annoyance Manager
// =================================================================================
class AnnoyanceManager {
/**
* @private
* @static
* @const {string}
*/
static STYLES = `
/* --- RGVDB Annoyance Removal --- */
/* Header: Link button to external site (Desktop) */
.topNav .aTab {
display: none !important;
}
/* Information Bar (Top Banner) */
.InformationBar {
display: none !important;
}
/* Ad Containers (:has() dependent) */
.sideBarItem:has(.liveAdButton) {
display: none !important;
}
/* Feed Injections (Trending Niches/Creators, Ads, etc.) */
.injection {
display: none !important;
}
/* Feed Modules (Suggested/Trending Niches, Suggested/Trending Creators, Mobile OF Creators, Niche Explorer) */
/* Backward compatibility: Keep existing class-based selectors combined with new attribute-based selectors */
.FeedModule:has(.nicheListWidget.trendingNiches),
.FeedModule:has(.seeMoreBlock.suggestedCreators),
.FeedModule:has(.seeMoreBlock.trendingCreators),
.FeedModule:has(.OnlyFansCreatorsModule),
.FeedModule:has(.nicheExplorer),
.FeedModule[data-feed-module-type="trending-niches"],
.FeedModule[data-feed-module-type="suggested-niches"],
.FeedModule[data-feed-module-type="trending-creators"],
.FeedModule[data-feed-module-type="suggested-creators"],
.FeedModule[data-feed-module-type="only-fans"],
.FeedModule[data-feed-module-type="live-cam"],
.FeedModule[data-feed-module-type="boost"] {
display: none !important;
}
/* Sidebar: OnlyFans Creators (Desktop) */
/* Use visibility:hidden to hide without affecting layout (prevents center feed shift) */
.OnlyFansCreatorsSidebar {
visibility: hidden !important;
}
`;
/**
* Injects the annoyance removal CSS into the document's head.
*/
injectStyles() {
const styleElement = h('style', { type: 'text/css', 'data-owner': `${APPID}-annoyances` }, AnnoyanceManager.STYLES);
document.head.appendChild(styleElement);
}
/**
* Registers Sentinel observers to hide elements that cannot be hidden by CSS alone or require dynamic content injection.
* @param {Sentinel} sentinel - The Sentinel instance.
*/
removeElements(sentinel) {
// Helper to hide ad containers (VisibleOnly elements often cause layout shifts or blank spaces)
const adHider = (adElement) => {
const adContainer = adElement.closest('.GifPreview.VisibleOnly');
if (adContainer instanceof HTMLElement) {
// Do NOT use .remove() as it breaks the site's virtual DOM state.
// Use inline style to force hide.
adContainer.style.setProperty('display', 'none', 'important');
}
};
// --- Unified Annoyance Hiding ---
// Handles Live Cam streams (Streamate) on both Desktop and Mobile.
// Selectors are updated to match current site structure (.StreamateCameraDispatcher).
sentinel.on('.StreamateCameraDispatcher', adHider);
// Handle Boosted Ad Posts
sentinel.on('.metaInfo_isBoosted', (infoElement) => {
const container = infoElement.closest('.GifPreview');
if (container instanceof HTMLElement) {
container.style.setProperty('display', 'none', 'important');
}
});
}
}
// =================================================================================
// SECTION: Sentinel (DOM Node Insertion Observer)
// =================================================================================
/**
* @class Sentinel
* @description Detects DOM node insertion using a shared, prefixed CSS animation trick.
* @property {Map<string, Array<(element: Element) => void>>} listeners
* @property {Set<string>} rules
* @property {HTMLElement | null} styleElement
* @property {CSSStyleSheet | null} sheet
* @property {string[]} pendingRules
* @property {WeakMap<CSSRule, string>} ruleSelectors
*/
class Sentinel {
/**
* @param {string} prefix - A unique identifier for this Sentinel instance to avoid CSS conflicts. Required.
*/
constructor(prefix) {
if (!prefix) {
throw new Error('[Sentinel] "prefix" argument is required to avoid CSS conflicts.');
}
/** @type {any} */
const globalScope = window;
globalScope.__global_sentinel_instances__ = globalScope.__global_sentinel_instances__ || {};
if (globalScope.__global_sentinel_instances__[prefix]) {
return globalScope.__global_sentinel_instances__[prefix];
}
// Use a unique, prefixed animation name shared by all scripts in a project.
this.animationName = `${prefix}-global-sentinel-animation`;
this.styleId = `${prefix}-sentinel-global-rules`; // A single, unified style element
this.listeners = new Map();
this.rules = new Set(); // Tracks all active selectors
this.styleElement = null; // Holds the reference to the single style element
this.sheet = null; // Cache the CSSStyleSheet reference
this.pendingRules = []; // Queue for rules requested before sheet is ready
/** @type {WeakMap<CSSRule, string>} */
this.ruleSelectors = new WeakMap(); // Tracks selector strings associated with CSSRule objects
this._injectStyleElement();
document.addEventListener('animationstart', this._handleAnimationStart.bind(this), true);
globalScope.__global_sentinel_instances__[prefix] = this;
}
_injectStyleElement() {
// Ensure the style element is injected only once per project prefix.
this.styleElement = document.getElementById(this.styleId);
if (this.styleElement instanceof HTMLStyleElement) {
this.sheet = this.styleElement.sheet;
return;
}
// Create empty style element
this.styleElement = h('style', {
id: this.styleId,
});
// CSP Fix: Try to fetch a valid nonce from existing scripts/styles
// "nonce" property exists on HTMLScriptElement/HTMLStyleElement, not basic Element.
let nonce;
const script = document.querySelector('script[nonce]');
const style = document.querySelector('style[nonce]');
if (script instanceof HTMLScriptElement) {
nonce = script.nonce;
} else if (style instanceof HTMLStyleElement) {
nonce = style.nonce;
}
if (nonce) {
this.styleElement.setAttribute('nonce', nonce);
}
// Try to inject immediately. If the document is not yet ready (e.g. extremely early document-start), wait for the root element.
const target = document.head || document.documentElement;
const initSheet = () => {
if (this.styleElement instanceof HTMLStyleElement) {
this.sheet = this.styleElement.sheet;
// Insert the shared keyframes rule at index 0.
try {
const keyframes = `@keyframes ${this.animationName} { from { transform: none; } to { transform: none; } }`;
this.sheet.insertRule(keyframes, 0);
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, 'Failed to insert keyframes rule:', e);
}
this._flushPendingRules();
}
};
if (target) {
target.appendChild(this.styleElement);
initSheet();
} else {
const observer = new MutationObserver(() => {
const retryTarget = document.head || document.documentElement;
if (retryTarget) {
observer.disconnect();
retryTarget.appendChild(this.styleElement);
initSheet();
}
});
observer.observe(document, { childList: true });
}
}
_flushPendingRules() {
if (!this.sheet || this.pendingRules.length === 0) return;
const rulesToInsert = [...this.pendingRules];
this.pendingRules = [];
rulesToInsert.forEach((selector) => {
this._insertRule(selector);
});
}
/**
* Helper to insert a single rule into the stylesheet
* @param {string} selector
*/
_insertRule(selector) {
try {
const index = this.sheet.cssRules.length;
const ruleText = `${selector} { animation-duration: 0.001s; animation-name: ${this.animationName}; }`;
this.sheet.insertRule(ruleText, index);
// Associate the inserted rule with the selector via WeakMap for safer removal later.
// This mimics sentinel.js behavior to handle index shifts and selector normalization.
const insertedRule = this.sheet.cssRules[index];
if (insertedRule) {
this.ruleSelectors.set(insertedRule, selector);
}
} catch (e) {
Logger.error('SENTINEL', LOG_STYLES.RED, `Failed to insert rule for selector "${selector}":`, e);
}
}
_handleAnimationStart(event) {
// Check if the animation is the one we're listening for.
if (event.animationName !== this.animationName) return;
const target = event.target;
if (!(target instanceof Element)) {
return;
}
// Check if the target element matches any of this instance's selectors.
for (const [selector, callbacks] of this.listeners.entries()) {
if (target.matches(selector)) {
// Use a copy of the callbacks array in case a callback removes itself.
[...callbacks].forEach((cb) => cb(target));
}
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
on(selector, callback) {
// Add callback to listeners
if (!this.listeners.has(selector)) {
this.listeners.set(selector, []);
}
this.listeners.get(selector).push(callback);
// If selector is already registered in rules, do nothing
if (this.rules.has(selector)) return;
this.rules.add(selector);
// Apply rule
if (this.sheet) {
this._insertRule(selector);
} else {
this.pendingRules.push(selector);
}
}
/**
* @param {string} selector
* @param {(element: Element) => void} callback
*/
off(selector, callback) {
const callbacks = this.listeners.get(selector);
if (!callbacks) return;
const newCallbacks = callbacks.filter((cb) => cb !== callback);
if (newCallbacks.length === callbacks.length) {
return; // Callback not found, do nothing.
}
if (newCallbacks.length === 0) {
// Remove listener and rule
this.listeners.delete(selector);
this.rules.delete(selector);
if (this.sheet) {
// Iterate backwards to avoid index shifting issues during deletion
for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
const rule = this.sheet.cssRules[i];
// Check for recorded selector via WeakMap or fallback to selectorText match
const recordedSelector = this.ruleSelectors.get(rule);
if (recordedSelector === selector || (rule instanceof CSSStyleRule && rule.selectorText === selector)) {
this.sheet.deleteRule(i);
// We assume one rule per selector, so we can break after deletion
break;
}
}
}
} else {
this.listeners.set(selector, newCallbacks);
}
}
suspend() {
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = true;
}
Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Suspended.');
}
resume() {
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = false;
}
Logger.debug('SENTINEL', LOG_STYLES.CYAN, 'Resumed.');
}
}
// =================================================================================
// SECTION: Main Application Controller
// =================================================================================
/**
* @typedef {object} ButtonConfig
* @property {string} className - The CSS class for the button.
* @property {string} title - The button's tooltip text.
* @property {keyof ICONS} iconName - The key of the icon in the ICONS object.
*/
class AppController {
constructor() {
/** @type {ConfigManager} */
this.configManager = new ConfigManager();
/** @type {ApiManager} */
this.apiManager = new ApiManager();
/** @type {UIManager} */
this.ui = new UIManager(this.configManager); // Pass configManager to UIManager
/** @type {AnnoyanceManager} */
this.annoyanceManager = new AnnoyanceManager();
/** @type {Map<string, AbortController>} */
this.activeDownloads = new Map();
/** @type {SettingsModal|null} */
this.settingsModal = null;
// Subscribe to config updates to refresh button icons
EventBus.subscribe(EVENTS.CONFIG_UPDATED, (config) => this._handleConfigUpdate(config), createEventKey(this, EVENTS.CONFIG_UPDATED));
}
/**
* Initializes the script.
*/
async init() {
// 1. Load configuration asynchronously
await this.configManager.load();
// 2. Initialize Settings Modal and register menu command
this.settingsModal = new SettingsModal(this.configManager);
GM.registerMenuCommand('Open Settings', () => {
this.settingsModal.open();
});
// 3. Inject annoyance removal styles
this.annoyanceManager.injectStyles();
// 4. Inject script UI (buttons, toast) styles
this.ui.init();
const sentinel = new Sentinel(OWNERID);
// 5. Register JS-based annoyance removal
this.annoyanceManager.removeElements(sentinel);
/**
* Registers a Sentinel observer.
* @param {string} selector The CSS selector to observe.
* @param {(element: Element) => void} handler The callback handler for found elements.
*/
const registerObserver = (selector, handler) => {
sentinel.on(selector, handler);
};
// Shared ID extractor for dataset-based IDs
const getFeedId = (el) => {
// Ensure element is HTMLElement to access dataset
if (!(el instanceof HTMLElement)) return null;
// ID is in 'data-feed-item-id'
const feedId = el.dataset.feedItemId;
// Filter out non-video items (e.g. 'feed-module-...') and normalize to lowercase
if (feedId && !feedId.startsWith('feed-module-')) {
return feedId.toLowerCase();
}
// Fallback: Check for ID attribute if layout reverts or mixed
// Tile IDs were just the ID, Preview IDs were 'gif_ID'
if (el.id) {
const idPart = el.id.startsWith('gif_') ? el.id.split('_')[1] : el.id;
return idPart ? idPart.toLowerCase() : null;
}
return null;
};
// Set up the listener using Sentinel.
// When Sentinel registers a new selector, it rewrites its stylesheet.
// This triggers the animationstart event for both elements
// that already exist in the DOM and elements added later.
// Setup observer for Tile Items (Grid View)
registerObserver(CONSTANTS.TILE_ITEM_SELECTOR, (element) => {
if (element instanceof HTMLElement) {
this._onElementFound(element, getFeedId, CONSTANTS.CONTEXT_TYPE.TILE);
}
});
// Setup observer for Video Containers (Preview/Watch View)
registerObserver(CONSTANTS.VIDEO_CONTAINER_SELECTOR, (element) => {
if (element instanceof HTMLElement) {
this._onElementFound(element, getFeedId, CONSTANTS.CONTEXT_TYPE.PREVIEW);
}
});
Logger.log('INIT', LOG_STYLES.GREEN, 'Initialized and observing DOM for new content.');
}
/**
* Generic handler for found elements (replaces _onTileItemFound and _onPreviewFound).
* @param {HTMLElement} element The found DOM element.
* @param {(element: HTMLElement) => string|null} idExtractor A function to extract the video ID from the element.
* @param {string} type The context type of the element (from CONSTANTS.CONTEXT_TYPE).
* @private
*/
_onElementFound(element, idExtractor, type) {
if (!element) {
return;
}
const videoId = idExtractor(element);
// Robust check: Ensure videoId is truthy (not null, undefined, or empty string)
if (videoId) {
this._addButtonsToElement(element, videoId, type);
}
}
/**
* Adds buttons to a given element.
* @param {HTMLElement} element The parent element for the buttons.
* @param {string} videoId The video ID associated with the buttons.
* @param {string} type The context type (from CONSTANTS.CONTEXT_TYPE).
* @private
*/
_addButtonsToElement(element, videoId, type) {
const isTile = type === CONSTANTS.CONTEXT_TYPE.TILE;
// --- 1. Open in New Tab Button (Link) ---
// Always create the button elements. Visibility is toggled via CSS based on settings.
{
const className = isTile ? `${APPID}-open-in-new-tab-btn` : `${APPID}-preview-open-btn`;
const url = `${CONSTANTS.WATCH_URL_BASE}${videoId}`;
// Determine icon and title based on config
const config = this.configManager.get();
const isClean = config.openInNewTab.viewerType === 'clean';
const iconName = isClean ? 'PLAY_ARROW' : 'OPEN_IN_NEW';
const title = isClean ? 'Play in Clean Viewer' : 'Open in new tab';
this.ui.createLinkButton({
parentElement: element,
className: className,
title: title,
iconName: iconName,
href: url,
// Intercept click if 'Clean Viewer' is enabled
clickHandler: (e) => {
// Re-check config at click time to ensure latest setting is used
const currentConfig = this.configManager.get();
if (currentConfig.openInNewTab.viewerType === 'clean') {
e.preventDefault();
e.stopPropagation();
const videoInfo = this.apiManager.getCachedVideoInfo(videoId);
if (videoInfo) {
this._openCleanViewer(videoInfo, videoId);
} else {
// Fallback if info not cached: open standard page
window.open(url, '_blank');
}
} else {
// Default behavior: stop propagation to prevent parent navigation, but let the link work
e.stopPropagation();
}
},
});
}
// --- 2. Download Button (Action) ---
{
const className = isTile ? `${APPID}-tile-download-btn` : `${APPID}-preview-download-btn`;
const clickHandler = (e) => this._handleDownloadClick(e, videoId);
this.ui.createButton({
parentElement: element,
className: className,
title: 'Download HD Video',
iconName: 'DOWNLOAD',
clickHandler: clickHandler,
});
}
}
/**
* Handles configuration updates to refresh UI components.
* @param {object} config The new configuration object.
* @private
*/
_handleConfigUpdate(config) {
const isClean = config.openInNewTab.viewerType === 'clean';
const icon = isClean ? 'PLAY_ARROW' : 'OPEN_IN_NEW';
const title = isClean ? 'Play in Clean Viewer' : 'Open in new tab';
// Update Tile Buttons
this.ui.updateButtonVisuals(`.${APPID}-open-in-new-tab-btn`, icon, title);
// Update Preview Buttons
this.ui.updateButtonVisuals(`.${APPID}-preview-open-btn`, icon, title);
}
/**
* Handles the click event on the download button.
* Manages download start, 1s lock, and cancellation.
* @param {MouseEvent} e - The click event.
* @param {string} videoId - The ID of the video to download.
* @private
*/
async _handleDownloadClick(e, videoId) {
e.stopPropagation(); // Prevent parent elements from handling the click.
const button = e.currentTarget;
if (!(button instanceof HTMLButtonElement)) return; // Type Guard
// --- 1. Cancellation Logic ---
// Check if this videoId is already being downloaded
if (this.activeDownloads.has(videoId)) {
// If the button is disabled, it's in the 1s lock, ignore the click
if (button.disabled) return;
// Button is enabled (LOADING_CANCELLABLE), proceed with cancellation
Logger.log('DOWNLOAD', LOG_STYLES.YELLOW, `Cancelling download for ${videoId}...`);
const controller = this.activeDownloads.get(videoId);
controller.abort(); // Trigger the abort signal
// No need to delete from map here, the finally block in the original call will handle it.
// No toast here for cancellation click, only log. Toast is shown if the fetch promise rejects with AbortError.
this.ui.updateButtonState(button, 'IDLE'); // Reset button immediately
return;
}
// --- 2. Download Start Logic ---
if (button.disabled) return; // Should not happen if state is IDLE, but as a safeguard.
const controller = new AbortController();
this.activeDownloads.set(videoId, controller);
// Set state to LOADING_LOCKED (Spinner, disabled: true)
this.ui.updateButtonState(button, 'LOADING_LOCKED');
this.ui.showToast('Download started...', 'info');
// Transition to cancellable state
setTimeout(() => {
// Only transition if the download is still active
if (this.activeDownloads.has(videoId)) {
this.ui.updateButtonState(button, 'LOADING_CANCELLABLE');
}
}, CONSTANTS.CANCEL_LOCK_DURATION);
try {
// --- 2a. Check Cache ---
const videoInfo = this.apiManager.getCachedVideoInfo(videoId);
if (videoInfo) {
// --- 2b. [Cache Hit] Execute Download ---
Logger.log('CACHE HIT', LOG_STYLES.TEAL, `Starting download for ${videoId}`);
await this._executeDownload(videoInfo, videoId, controller.signal);
// --- 2c. Handle Success ---
this.ui.updateButtonState(button, 'SUCCESS');
this.ui.showToast('Download successful!', 'success');
Logger.log('DOWNLOAD', LOG_STYLES.GREEN, `Downloaded ${videoId} from:`, videoInfo.hdUrl);
} else {
// --- 2d. [Cache Miss] Handle Failure ---
Logger.warn('CACHE MISS', LOG_STYLES.YELLOW, `Video info not found in cache for ${videoId}.`);
this.ui.showToast('Video info not found in cache. (Try scrolling or refreshing)', 'error');
this.ui.updateButtonState(button, 'ERROR');
}
} catch (error) {
// --- 2e. Handle Errors (including AbortError) ---
if (error.name === 'AbortError') {
// Handle cancellation specifically (when the promise rejects)
Logger.log('DOWNLOAD', LOG_STYLES.YELLOW, `Download process for ${videoId} was aborted.`);
this.ui.showToast('Download cancelled.', 'info');
// Button state should be reset by the click handler that initiated the abort
// If the abort happened for other reasons (e.g., page navigation), this ensures cleanup
if (this.activeDownloads.has(videoId)) {
// Check if cleanup is needed
this.ui.updateButtonState(button, 'IDLE');
}
} else if (error instanceof HttpError && error.status === 404) {
Logger.warn('DOWNLOAD', LOG_STYLES.YELLOW, `Download failed: Not Found (404) for ${videoId}`, error);
this.ui.showToast('Video not found (404).', 'error');
this.ui.updateButtonState(button, 'ERROR');
} else if (error instanceof HttpError && error.status === 403) {
Logger.warn('DOWNLOAD', LOG_STYLES.YELLOW, `Download failed: Forbidden (403) for ${videoId}`, error);
this.ui.showToast('Access forbidden (403).', 'error');
this.ui.updateButtonState(button, 'ERROR');
} else {
// Handle all other errors (API, Download, Network, 5xx, etc.) uniformly
Logger.error('DOWNLOAD', LOG_STYLES.RED, 'Download failed:', error); // Keep existing detailed log for developer
const userErrorMessage = 'Download failed. (Network error or site update?)';
this.ui.showToast(userErrorMessage, 'error'); // Show unified message to user
this.ui.updateButtonState(button, 'ERROR'); // Update button state
}
} finally {
// --- 3. Cleanup ---
// Always remove the task from the map when the process finishes (success, error, or abort)
this.activeDownloads.delete(videoId);
}
}
/**
* Performs the actual download process (file save).
* @param {{hdUrl: string, userName: string|undefined, createDate: number|undefined}} videoInfo - The video info object from the cache.
* @param {string} videoId - The ID of the video to download (for filename).
* @param {AbortSignal} signal - The AbortSignal to cancel the fetch operations.
* @returns {Promise<void>}
* @private
*/
async _executeDownload(videoInfo, videoId, signal) {
const config = this.configManager.get();
// --- A. Get Video Info ---
const { hdUrl, userName, createDate } = videoInfo;
const downloadUrl = hdUrl;
// --- B. Resolve Filename ---
const dateString = createDate && typeof createDate === 'number' ? formatTimestamp(createDate) : '';
const replacements = {
user: userName || '',
date: dateString,
id: videoId || '',
};
const baseFilename = resolveFilename(config.download.filenameTemplate, replacements);
// --- Dynamic Extension ---
let extension = getExtension(hdUrl);
if (!extension) {
Logger.warn('DOWNLOAD', LOG_STYLES.YELLOW, `Could not determine extension from URL. Defaulting to '.mp4'. URL:`, hdUrl);
extension = '.mp4'; // Fallback to ".mp4" if extraction fails
}
// The _downloadFile method will sanitize this filename further if needed.
const filename = `${baseFilename}${extension}`;
// --- C. Download File ---
await this._downloadFile(downloadUrl, filename, signal);
}
/**
* Initiates a download for the given URL using fetch and saves the file.
* @param {string} url The URL of the video to download.
* @param {string} filename The desired filename for the downloaded video.
* @param {AbortSignal} [signal] - An optional AbortSignal to cancel the request.
* @returns {Promise<void>}
* @private
*/
async _downloadFile(url, filename, signal) {
const response = await fetch(url, { signal }); // Pass signal to fetch
// Throw a more user-friendly error message for HTTP errors.
if (!response.ok) {
// Use HttpError for status code handling
throw new HttpError(response.status, `Server responded with ${response.status}`);
}
const videoBlob = await response.blob();
let objectUrl = null;
let link = null;
try {
objectUrl = URL.createObjectURL(videoBlob);
link = h('a', {
href: objectUrl,
download: filename,
});
if (link instanceof HTMLElement) {
document.body.appendChild(link);
link.click();
}
} finally {
if (link instanceof HTMLElement) {
document.body.removeChild(link);
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
}
}
/**
* Opens the video in a clean, minimalist viewer in a new tab.
* @param {object} videoInfo - The cached video info.
* @param {string} videoId - The video ID.
* @private
*/
_openCleanViewer(videoInfo, videoId) {
const { hdUrl, userName } = videoInfo;
const watchUrl = `${CONSTANTS.WATCH_URL_BASE}${videoId}`;
// Construct title: "UserName - VideoID" or fallback to "RedGIFs - VideoID"
const pageTitle = userName ? `${userName} - ${videoId}` : `RedGIFs - ${videoId}`;
const newWindow = window.open('', '_blank');
if (!newWindow) {
this.ui.showToast('Popup blocked. Please allow popups for this site.', 'error');
return;
}
// Security: Disconnect opener reference safely
try {
newWindow.opener = null;
} catch {
// Ignore: Some browsers may disallow setting opener
}
const doc = newWindow.document;
// DOM Initialization Safety
// Ensure essential nodes exist. If document is fundamentally broken, fallback to standard page.
try {
if (!doc || !doc.documentElement) {
throw new Error('Document structure is not ready');
}
// Auto-heal missing head/body (common in about:blank)
if (!doc.head) doc.documentElement.appendChild(doc.createElement('head'));
if (!doc.body) doc.documentElement.appendChild(doc.createElement('body'));
} catch (e) {
// Fallback: Navigate the blank window to the standard watch page
newWindow.location.href = watchUrl;
return;
}
doc.title = pageTitle;
// Apply body styles directly
Object.assign(doc.body.style, {
margin: '0',
padding: '0',
backgroundColor: '#000',
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
});
// Create styles for Back Link and Video
const styleEl = doc.createElement('style');
styleEl.textContent = `
video {
max-width: 100%;
max-height: 100%;
outline: none;
box-shadow: 0 0 20px rgb(0 0 0 / 0.5);
}
.back-link {
position: absolute;
top: 16px;
right: 16px;
color: rgb(255 255 255 / 0.5);
text-decoration: none;
background: rgb(0 0 0 / 0.5);
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
backdrop-filter: blur(4px);
transition: color 0.2s, background 0.2s;
z-index: 9999;
}
.back-link:hover {
color: #fff;
background: rgb(0 0 0 / 0.8);
}
`;
doc.head.appendChild(styleEl);
// Create Video Element
const videoEl = doc.createElement('video');
videoEl.src = hdUrl;
videoEl.controls = true;
videoEl.autoplay = true;
videoEl.loop = true;
videoEl.muted = true; // Required for autoplay
videoEl.playsInline = true;
doc.body.appendChild(videoEl);
// Create Back Link Element
const linkEl = doc.createElement('a');
linkEl.href = watchUrl;
linkEl.className = 'back-link';
linkEl.target = '_blank';
linkEl.rel = 'noopener noreferrer';
linkEl.textContent = 'Open Original Page';
doc.body.appendChild(linkEl);
}
}
// =================================================================================
// SECTION: Entry Point
// =================================================================================
if (ExecutionGuard.hasExecuted()) return;
ExecutionGuard.setExecuted();
// 1. Instantiate controller immediately at document-start.
// The constructor sets up the JSON.parse interceptor (ApiManager).
const app = new AppController();
// 2. Defer the UI initialization (init()) until the DOM is ready, as UIManager and Sentinel need access to document.body.
// init() is now async because it loads configuration first.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
app.init().catch((e) => {
Logger.error('INIT', LOG_STYLES.RED, 'Failed to initialize app:', e);
});
});
} else {
// Already 'interactive' or 'complete'
app.init().catch((e) => {
Logger.error('INIT', LOG_STYLES.RED, 'Failed to initialize app:', e);
});
}
})();