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 1.9.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 none
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
'use strict';
// =================================================================================
// SECTION: Script-Specific Definitions
// =================================================================================
const OWNERID = 'p65536';
const APPID = 'rgvdb';
const LOG_PREFIX = `[${APPID.toUpperCase()}]`;
// =================================================================================
// SECTION: USER SETTINGS (Customizable)
// =================================================================================
const USER_SETTINGS = {
/**
* 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,
},
};
// =================================================================================
// SECTION: INTERNAL CONSTANTS (Do not modify below this line)
// =================================================================================
const CONSTANTS = {
// Maps the standalone settings object here for internal usage.
USER_SETTINGS: USER_SETTINGS,
VIDEO_CONTAINER_SELECTOR: '[id^="gif_"]',
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
/**
* @enum {string}
* Defines the context where buttons are added.
*/
CONTEXT_TYPE: {
TILE: 'TILE',
PREVIEW: 'PREVIEW',
},
};
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' } },
],
},
};
// =================================================================================
// SECTION: Style Definitions
// =================================================================================
const STYLES = `
/* 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: rgba(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: rgba(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: 1000;
width: 32px;
height: 32px;
padding: 4px;
border-radius: 4px;
background-color: rgba(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: rgba(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: 1000;
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;
}
/* 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%); }
}
`;
// =================================================================================
// SECTION: Execution Guard
// =================================================================================
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: Logging Utility
// =================================================================================
// Style definitions for styled Logger.badge()
const LOG_STYLES = {
BASE: 'color: white; padding: 2px 6px; border-radius: 4px; font-weight: bold;',
INFO: 'background: #007bff;',
LOG: 'background: #28a745;',
WARN: 'background: #ffc107; color: black;',
ERROR: 'background: #dc3545;',
};
class Logger {
static log(...args) {
console.log(LOG_PREFIX, ...args);
}
static warn(...args) {
console.warn(LOG_PREFIX, ...args);
}
static error(...args) {
console.error(LOG_PREFIX, ...args);
}
/**
* Logs a message with a styled badge for better visibility.
* @param {string} badgeText - The text inside the badge.
* @param {string} badgeStyle - The background-color style (from LOG_STYLES).
* @param {'log'|'warn'|'error'} level - The console log level.
* @param {...any} args - Additional messages to log after the badge.
*/
static badge(badgeText, badgeStyle, level, ...args) {
const style = `${LOG_STYLES.BASE} ${badgeStyle}`;
const consoleMethod = console[level] || console.log;
consoleMethod(
`%c${LOG_PREFIX}%c %c${badgeText}%c`,
'font-weight: bold;', // Style for the prefix
'color: inherit;', // Reset for space
style, // Style for the badge
'color: inherit;', // Reset for the rest of the message
...args
);
}
}
// =================================================================================
// SECTION: Utility Functions
// =================================================================================
/**
* @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(`Blocked potentially unsafe protocol "${parsedUrl.protocol}" in attribute "${key}":`, url);
}
} catch {
el.setAttribute(key, '#');
Logger.warn(`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') && 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) {
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);
return el;
}
/**
* 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('Could not parse URL to get extension:', url, e);
return ''; // Return empty on URL parsing failure
}
}
/**
* 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;
})();
/**
* 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('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: 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) {
return this.videoCache.get(videoId);
}
/**
* Sets up JSON.parse interceptor to capture API responses.
* @private
*/
_initJsonInterceptor() {
const originalJsonParse = JSON.parse;
const self = this; // Preserve ApiManager instance for use in the closure
JSON.parse = function (text, reviver) {
// Pass through to the original parser first
const result = originalJsonParse.call(this, text, reviver);
try {
// 1. Check if the result is an object
if (result && typeof result === 'object') {
// 2. Check for the specific keys we care about (result.gifs or result.gif)
if (Array.isArray(result.gifs) || (result.gif && typeof result.gif === 'object')) {
// 3. If it matches, process the data
self._processApiData(result);
}
}
} catch (error) {
Logger.error('Error during JSON.parse interception:', error, result);
}
// Always return the original result
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) {
if (!this.videoCache.has(videoId)) {
// 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(videoId, { hdUrl, userName, createDate });
count++;
}
}
}
// Log on successful processing
const path = '[JSON_PARSE]';
if (count > 0) {
Logger.badge('CACHE UPDATED', LOG_STYLES.INFO, 'log', `${path} Added ${count} new items. Total: ${this.videoCache.size}`);
} else {
// Log if the feed contained items, but all were already cached.
Logger.badge('API HIT', LOG_STYLES.LOG, 'log', `${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('Failed to process API data object:', error, data);
}
}
}
// =================================================================================
// SECTION: UI Manager
// =================================================================================
class UIManager {
constructor() {
/** @type {HTMLElement|null} */
this.toastContainer = null;
}
/**
* Initializes the UI components that require the DOM.
* Creates and appends the toast container to the document body.
*/
init() {
this._createToastContainer();
}
/**
* Injects the necessary CSS styles for the script's UI into the document's head.
*/
injectStyles() {
this._injectStyles();
}
/**
* Creates and appends the toast container to the document body.
* @private
*/
_createToastContainer() {
this.toastContainer = h(`div.${APPID}-toast-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('Toast container element not found. Cannot display toast.');
return;
}
const toastClass = `${APPID}-toast-${type}`;
const toastElement = h(`div.${APPID}-toast`, { class: 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,
});
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 {'DOWNLOAD'|'SPINNER'|'SUCCESS'|'ERROR'|'OPEN_IN_NEW'} iconName The name of the icon to set.
* @private
*/
_setButtonIcon(button, iconName) {
const cachedIcon = CACHED_ICONS[iconName];
if (!cachedIcon) {
Logger.error(`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,
});
this._setButtonIcon(button, iconName);
parentElement.appendChild(button);
}
/**
* Injects the necessary CSS styles into the document's head.
* @private
*/
_injectStyles() {
// 1. Base Styles
let css = STYLES;
// 2. Dynamic Styles based on USER_SETTINGS
const settings = CONSTANTS.USER_SETTINGS;
// Define class names locally since config object was removed
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`,
};
// Helper to generate hover-only styles
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 'Show Only On Hover' globally if enabled
if (settings.common.showOnlyOnHover) {
// 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);
}
// 3. Layout Adjustments (if Open in New Tab is disabled)
if (!settings.openInNewTab.enabled) {
// Move Download buttons up to fill the gap
css += `
/* Move Download Button to top position (8px) */
.${CLS.TILE_DOWNLOAD} { top: 8px !important; }
.${CLS.PREVIEW_DOWNLOAD} { top: 8px !important; }
/* Mobile adjustment for Preview (64px = Toolbar + 8px) */
.App.phone .${CLS.PREVIEW_DOWNLOAD} { top: 64px !important; }
`;
}
const styleElement = h('style', { type: 'text/css', 'data-owner': APPID }, css);
document.head.appendChild(styleElement);
}
}
// =================================================================================
// 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) */
.FeedModule:has(.nicheListWidget.trendingNiches),
.FeedModule:has(.seeMoreBlock.suggestedCreators),
.FeedModule:has(.seeMoreBlock.trendingCreators),
.FeedModule:has(.OnlyFansCreatorsModule),
.FeedModule:has(.nicheExplorer) {
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) {
// --- Section: Platform-Specific Dynamic Ad Hiding ---
// This logic detects the platform (phone/desktop) once on load and registers the appropriate ad hider for that platform.
// --- Developer Note: Platform Detection ---
// This logic determines the platform (phone/desktop) ONCE on page load.
// It intentionally does NOT handle dynamic platform switching because the site itself does not support it (at least for now).
//
// TO TEST MOBILE ON DESKTOP: Enable device emulation in DevTools AND THEN reload the page.
// ---
let platformDetermined = false;
const adHider = (adElement) => {
const adContainer = adElement.closest('.GifPreview.VisibleOnly');
if (adContainer) {
// Do NOT use .remove() as it breaks the site's virtual DOM state, causing black screens on navigation.
// Instead, apply an inline !important style to win the CSS specificity war.
adContainer.style.setProperty('display', 'none', 'important');
}
};
// Define listener callbacks so they can be unregistered later.
const onPhoneFound = () => {
if (platformDetermined) return;
platformDetermined = true;
Logger.badge('PLATFORM', LOG_STYLES.INFO, 'log', 'Mobile platform detected');
sentinel.on('[data-videoads="adsVideo"]', adHider);
// Unregister the other platform watcher
sentinel.off('.App.desktop', onDesktopFound);
};
const onDesktopFound = () => {
if (platformDetermined) return;
platformDetermined = true;
Logger.badge('PLATFORM', LOG_STYLES.INFO, 'log', 'Desktop platform detected');
sentinel.on('[class*="_StreamateCamera_"]', adHider);
// Unregister the other platform watcher
sentinel.off('.App.phone', onPhoneFound);
};
// Rely on Sentinel to detect the platform class when it appears.
Logger.badge('PLATFORM', LOG_STYLES.INFO, 'log', 'Awaiting platform detection...');
sentinel.on('.App.phone', onPhoneFound);
sentinel.on('.App.desktop', onDesktopFound);
// --- Section: General Annoyance Hiding ---
// These are platform-independent rules that simply hide specific elements.
// Handle Boosted Ad Posts
sentinel.on('.metaInfo_isBoosted', (infoElement) => {
const container = infoElement.closest('.GifPreview');
if (container) {
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
*/
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
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,
});
// 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 and keep it there.
// We use insertRule for performance instead of textContent replacement.
try {
const keyframes = `@keyframes ${this.animationName} { from { transform: none; } to { transform: none; } }`;
this.sheet.insertRule(keyframes, 0);
} catch (e) {
Logger.badge('SENTINEL', LOG_STYLES.RED, 'error', '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);
// Tag the inserted rule object with the selector for safer removal later.
// This mimics sentinel.js behavior to handle index shifts and selector normalization.
const insertedRule = this.sheet.cssRules[index];
if (insertedRule) {
// @ts-ignore - Custom property for tracking
insertedRule._id = selector;
}
} catch (e) {
Logger.badge('SENTINEL', LOG_STYLES.RED, 'error', `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 custom tag or fallback to selectorText match
// @ts-ignore
if (rule._id === selector || 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.badge('SENTINEL', LOG_STYLES.GRAY, 'debug', 'Suspended.');
}
resume() {
if (this.styleElement instanceof HTMLStyleElement) {
this.styleElement.disabled = false;
}
Logger.badge('SENTINEL', LOG_STYLES.GRAY, 'debug', '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 {ApiManager} */
this.apiManager = new ApiManager();
/** @type {UIManager} */
this.ui = new UIManager();
/** @type {AnnoyanceManager} */
this.annoyanceManager = new AnnoyanceManager();
/** @type {Map<string, AbortController>} */
this.activeDownloads = new Map();
}
/**
* Initializes the script.
*/
init() {
// 1. Inject annoyance removal styles
this.annoyanceManager.injectStyles();
// 2. Inject script UI (buttons, toast) styles
this.ui.injectStyles();
// 3. Initialize UI components (toast container)
this.ui.init();
const sentinel = new Sentinel(OWNERID);
// 4. 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);
};
// 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) => {
this._onElementFound(
element,
(id) => id, // Tile ID is the video ID
CONSTANTS.CONTEXT_TYPE.TILE
);
});
// Setup observer for Video Containers (Preview/Watch View)
registerObserver(CONSTANTS.VIDEO_CONTAINER_SELECTOR, (element) => {
this._onElementFound(
element,
(id) => id.split('_')[1], // Preview ID is "gif_VIDEOID"
CONSTANTS.CONTEXT_TYPE.PREVIEW
);
});
Logger.log('Initialized and observing DOM for new content.');
}
/**
* Generic handler for found elements (replaces _onTileItemFound and _onPreviewFound).
* @param {HTMLElement} element The found DOM element.
* @param {(id: string) => string} idParser A function to extract the video ID from the element's ID.
* @param {string} type The context type of the element (from CONSTANTS.CONTEXT_TYPE).
* @private
*/
_onElementFound(element, idParser, type) {
if (!element || !element.id) {
return;
}
const videoId = idParser(element.id);
// 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 settings = CONSTANTS.USER_SETTINGS;
const isTile = type === CONSTANTS.CONTEXT_TYPE.TILE;
// --- 1. Open in New Tab Button (Link) ---
if (settings.openInNewTab.enabled) {
const className = isTile ? `${APPID}-open-in-new-tab-btn` : `${APPID}-preview-open-btn`;
const url = `${CONSTANTS.WATCH_URL_BASE}${videoId}`;
this.ui.createLinkButton({
parentElement: element,
className: className,
title: 'Open in new tab',
iconName: 'OPEN_IN_NEW',
href: url,
clickHandler: (e) => e.stopPropagation(), // Only stop propagation to prevent parent navigation
});
}
// --- 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 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;
// --- 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(`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.badge('CACHE HIT', LOG_STYLES.LOG, 'log', `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(`Downloaded ${videoId} from:`, videoInfo.hdUrl);
} else {
// --- 2d. [Cache Miss] Handle Failure ---
Logger.warn(`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 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.status === 404) {
Logger.warn(`Download failed: Not Found (404) for ${videoId}`, error);
this.ui.showToast('Video not found (404).', 'error');
this.ui.updateButtonState(button, 'ERROR');
} else if (error.status === 403) {
Logger.warn(`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 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) {
// --- 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(CONSTANTS.USER_SETTINGS.download.filenameTemplate, replacements);
// --- Dynamic Extension ---
let extension = getExtension(hdUrl);
if (!extension) {
Logger.warn(`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) {
const err = new Error(`Server responded with ${response.status}`);
err.status = response.status;
throw err;
}
const videoBlob = await response.blob();
let objectUrl = null;
let link = null;
try {
objectUrl = URL.createObjectURL(videoBlob);
link = h('a', {
href: objectUrl,
download: filename,
});
document.body.appendChild(link);
link.click();
} finally {
if (link) {
document.body.removeChild(link);
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
}
}
}
// =================================================================================
// SECTION: Entry Point
// =================================================================================
if (ExecutionGuard.hasExecuted()) return;
ExecutionGuard.setExecuted();
// 1. Instantiate controller immediately at document-start.
// The constructor sets up the JSON.parse interceptor.
const app = new AppController();
// 2. Defer the UI initialization (init()) until the DOM is ready, as UIManager and Sentinel need access to document.body.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.init());
} else {
// Already 'interactive' or 'complete'
app.init();
}
})();