2033 lines
83 KiB
JavaScript
2033 lines
83 KiB
JavaScript
"use strict";
|
||
|
||
if (!globalThis.__localRedgifsScannerInstalled) {
|
||
globalThis.__localRedgifsScannerInstalled = true;
|
||
const CONTENT_SCRIPT_VERSION = "0.6.23";
|
||
|
||
function isRedgifsHost(hostname) {
|
||
const host = String(hostname || "").toLowerCase();
|
||
return host === "redgifs.com" || host.endsWith(".redgifs.com");
|
||
}
|
||
|
||
function normalizeUrl(value) {
|
||
if (!value || /^data:|^blob:/i.test(String(value))) return null;
|
||
try {
|
||
const parsed = new URL(String(value), document.baseURI);
|
||
if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) return null;
|
||
return parsed.toString();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function idFromUrl(value) {
|
||
const normalized = normalizeUrl(value);
|
||
if (!normalized) return null;
|
||
try {
|
||
const parsed = new URL(normalized);
|
||
const pageMatch = parsed.pathname.match(/\/(?:watch|ifr)\/([a-z0-9_-]+)/i);
|
||
if (pageMatch && pageMatch[1]) return pageMatch[1];
|
||
const filename = (parsed.pathname.split("/").pop() || "")
|
||
.replace(/\.(?:mp4|webm|m3u8|m4s|gif|jpe?g|png)$/i, "")
|
||
.replace(/-(?:mobile|poster|thumbnail|thumb|sd|hd)$/i, "");
|
||
return filename && /^[a-z0-9_-]+$/i.test(filename) ? filename : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function idFromPosterUrl(value) {
|
||
const normalized = normalizeUrl(value);
|
||
if (!normalized) return null;
|
||
try {
|
||
const parsed = new URL(normalized);
|
||
if (parsed.hostname.toLowerCase() !== "media.redgifs.com") return null;
|
||
const filename = parsed.pathname.split("/").pop() || "";
|
||
const match = filename.match(/^([a-z0-9_-]+)-(?:poster|thumbnail|thumb|mobile)\.(?:avif|webp|jpe?g|png)$/i);
|
||
return match && match[1] ? match[1] : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function mediaPosterImage(root) {
|
||
if (!root) return null;
|
||
const images = [];
|
||
if (root.tagName === "IMG") images.push(root);
|
||
images.push.apply(images, Array.from(root.querySelectorAll
|
||
? root.querySelectorAll("img[src], img[data-src], img[data-lazy-src]")
|
||
: []));
|
||
for (const image of images) {
|
||
const candidates = [
|
||
image.currentSrc,
|
||
image.getAttribute && image.getAttribute("src"),
|
||
image.getAttribute && image.getAttribute("data-src"),
|
||
image.getAttribute && image.getAttribute("data-lazy-src")
|
||
];
|
||
if (candidates.some((candidate) => Boolean(idFromPosterUrl(candidate)))) return image;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function cleanText(value, fallback) {
|
||
const cleaned = String(value || "")
|
||
.replace(/^Poster for\s+/i, "")
|
||
.replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "")
|
||
.replace(/\s+/g, " ")
|
||
.replace(/\b(?:like|share|sound|more|follow|views?)\b/gi, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
return (cleaned || fallback || "RedGifs video").slice(0, 160);
|
||
}
|
||
|
||
function videoSource(video) {
|
||
if (!video) return null;
|
||
const candidates = [
|
||
video.currentSrc,
|
||
video.src,
|
||
video.getAttribute("src"),
|
||
video.querySelector("source[src]") && video.querySelector("source[src]").getAttribute("src")
|
||
];
|
||
for (const candidate of candidates) {
|
||
const normalized = normalizeUrl(candidate);
|
||
if (normalized && !/\.m4s(?:$|\?)/i.test(normalized)) return normalized;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function posterFromRoot(root, video) {
|
||
const candidates = [];
|
||
if (video) {
|
||
candidates.push(video.poster, video.getAttribute("poster"));
|
||
}
|
||
const images = [];
|
||
if (root && root.tagName === "IMG") images.push(root);
|
||
images.push.apply(images, Array.from(root.querySelectorAll ? root.querySelectorAll("img[src], img[data-src], img[data-lazy-src]") : []));
|
||
images.sort((left, right) => Number(Boolean(idFromPosterUrl(
|
||
right.currentSrc || right.getAttribute("src") || right.getAttribute("data-src") || right.getAttribute("data-lazy-src")
|
||
))) - Number(Boolean(idFromPosterUrl(
|
||
left.currentSrc || left.getAttribute("src") || left.getAttribute("data-src") || left.getAttribute("data-lazy-src")
|
||
))));
|
||
for (const image of images.slice(0, 6)) {
|
||
candidates.push(
|
||
image.getAttribute("src"),
|
||
image.getAttribute("data-src"),
|
||
image.getAttribute("data-lazy-src")
|
||
);
|
||
}
|
||
for (const candidate of candidates) {
|
||
const normalized = normalizeUrl(candidate);
|
||
if (normalized) return normalized;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function titleFromRoot(root, id) {
|
||
const selectors = [
|
||
".descriptionText",
|
||
".description",
|
||
"[data-testid*='caption']",
|
||
"[data-testid*='description']",
|
||
"figcaption",
|
||
".userName",
|
||
".username"
|
||
];
|
||
const candidates = [];
|
||
for (const selector of selectors) {
|
||
const node = root.querySelector && root.querySelector(selector);
|
||
if (node && node.textContent) candidates.push(node.textContent);
|
||
}
|
||
candidates.push(
|
||
root.getAttribute && root.getAttribute("aria-label"),
|
||
root.getAttribute && root.getAttribute("title")
|
||
);
|
||
const image = root.querySelector && root.querySelector("img[alt]");
|
||
if (image) candidates.push(String(image.getAttribute("alt") || ""));
|
||
for (const candidate of candidates) {
|
||
const cleaned = cleanText(candidate, "");
|
||
if (cleaned && cleaned !== "RedGifs video" && cleaned.length > 2) return cleaned;
|
||
}
|
||
return id ? "RedGifs · " + id : "RedGifs video";
|
||
}
|
||
|
||
function currentProfileSlug() {
|
||
const match = String(location.pathname || "").match(/\/users\/([^/?#]+)/i);
|
||
if (!match || !match[1]) return null;
|
||
try {
|
||
return decodeURIComponent(match[1]);
|
||
} catch {
|
||
return match[1];
|
||
}
|
||
}
|
||
|
||
function normalizeProfileSlug(value) {
|
||
let slug = String(value || "").trim().replace(/^@+/, "");
|
||
try {
|
||
slug = decodeURIComponent(slug);
|
||
} catch {
|
||
// Keep the original visible username.
|
||
}
|
||
return /^[a-z0-9._-]{1,80}$/i.test(slug) ? slug : null;
|
||
}
|
||
|
||
function profileSlugFromRoot(root) {
|
||
if (!root) return null;
|
||
const profileLinks = Array.from(root.querySelectorAll
|
||
? root.querySelectorAll("a[href*='/users/']")
|
||
: []);
|
||
for (const link of profileLinks) {
|
||
const href = normalizeUrl(link.getAttribute && (link.getAttribute("href") || link.href));
|
||
if (!href) continue;
|
||
try {
|
||
const match = new URL(href).pathname.match(/\/users\/([^/?#]+)/i);
|
||
const slug = normalizeProfileSlug(match && match[1]);
|
||
if (slug) return slug;
|
||
} catch {
|
||
// Try the next profile hint.
|
||
}
|
||
}
|
||
|
||
const attributeNames = ["data-username", "data-user-name", "data-uploader", "data-owner"];
|
||
for (const name of attributeNames) {
|
||
const slug = normalizeProfileSlug(root.getAttribute && root.getAttribute(name));
|
||
if (slug) return slug;
|
||
}
|
||
|
||
const selectors = [
|
||
".userName",
|
||
".username",
|
||
"[data-testid='username']",
|
||
"[data-testid*='user-name']",
|
||
"[data-testid*='username']"
|
||
];
|
||
for (const selector of selectors) {
|
||
const node = root.querySelector && root.querySelector(selector);
|
||
const slug = normalizeProfileSlug(node && node.textContent);
|
||
if (slug) return slug;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function closestCard(node) {
|
||
if (!node || !node.closest) return node;
|
||
return node.closest("[data-feed-item-id], .GifPreview, .tileItem, article, figure")
|
||
|| node.closest("a[href*='/watch/'], a[href*='/ifr/']")
|
||
|| (node.tagName === "VIDEO" && node.parentElement ? node.parentElement : node);
|
||
}
|
||
|
||
function isExplicitMediaCard(root) {
|
||
if (!root || typeof root.matches !== "function") return false;
|
||
return root.matches([
|
||
"[data-feed-item-id]",
|
||
".GifPreview",
|
||
".tileItem",
|
||
"[data-testid*='feed-item']",
|
||
"[data-testid*='gif-preview']",
|
||
"[class*='GifPreview']",
|
||
"[class*='tileItem']"
|
||
].join(", "));
|
||
}
|
||
|
||
function ensureButtonStyles() {
|
||
if (document.getElementById("local-redgifs-download-styles")) return;
|
||
const style = document.createElement("style");
|
||
style.id = "local-redgifs-download-styles";
|
||
style.textContent = [
|
||
".local-rg-download-host{position:relative!important}",
|
||
".local-rg-scroll-host{position:relative!important}",
|
||
".local-rg-download-button{all:initial!important;box-sizing:border-box!important;position:absolute!important;right:10px!important;bottom:10px!important;left:auto!important;z-index:2147483646!important;display:flex!important;align-items:center!important;justify-content:center!important;width:42px!important;height:42px!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:50%!important;background:linear-gradient(135deg,#f04468,#d72d57)!important;color:#fff!important;box-shadow:0 8px 24px rgba(0,0,0,.42)!important;font:900 23px/1 system-ui,-apple-system,'Segoe UI',sans-serif!important;cursor:pointer!important;opacity:.96!important;transform:translateY(0)!important;transition:transform .15s ease,filter .15s ease,opacity .15s ease!important;user-select:none!important;-webkit-user-select:none!important}",
|
||
".local-rg-download-button:hover{filter:brightness(1.12)!important;transform:translateY(-1px)!important;opacity:1!important}",
|
||
".local-rg-download-button:active{transform:translateY(1px)!important}",
|
||
".local-rg-download-button:disabled{cursor:wait!important;filter:saturate(.55)!important;opacity:.88!important}",
|
||
".local-rg-download-button[data-state='working']{animation:localRgButtonWorking 1.05s ease-in-out infinite!important}",
|
||
".local-rg-download-button[data-state='done'],.local-rg-download-button[data-state='downloaded']{background:#159665!important}",
|
||
".local-rg-download-button[data-state='downloaded']{border-color:rgba(185,255,225,.72)!important;box-shadow:0 8px 24px rgba(0,0,0,.42),0 0 0 2px rgba(32,184,121,.22)!important}",
|
||
".local-rg-download-button[data-state='downloaded']:hover{background:linear-gradient(135deg,#ff9c45,#e86b21)!important;border-color:rgba(255,224,184,.82)!important;box-shadow:0 10px 28px rgba(0,0,0,.44),0 0 0 3px rgba(244,130,42,.25)!important}",
|
||
".local-rg-download-button[data-state='error']{background:#9e2941!important}",
|
||
".local-rg-button-glyph{all:initial!important;box-sizing:border-box!important;position:absolute!important;inset:0!important;display:grid!important;place-items:center!important;color:inherit!important;pointer-events:none!important;transition:opacity .18s ease,transform .24s cubic-bezier(.2,.8,.2,1)!important}",
|
||
".local-rg-button-glyph svg{display:block!important;width:25px!important;height:25px!important;overflow:visible!important;color:inherit!important;fill:none!important;stroke:currentColor!important;stroke-width:2.35!important;stroke-linecap:round!important;stroke-linejoin:round!important}",
|
||
".local-rg-icon-redownload{opacity:0!important;transform:rotate(-75deg) scale(.62)!important}",
|
||
".local-rg-download-button[data-state='downloaded']:hover .local-rg-icon-check{opacity:0!important;transform:rotate(35deg) scale(.62)!important}",
|
||
".local-rg-download-button[data-state='downloaded']:hover .local-rg-icon-redownload{opacity:1!important;transform:rotate(0) scale(1)!important}",
|
||
".local-rg-download-button[data-layout='feed-external']{position:absolute!important;right:auto!important;bottom:auto!important;left:var(--local-rg-left,8px)!important;top:var(--local-rg-top,8px)!important;z-index:2147483645!important;display:none!important}",
|
||
".local-rg-download-button[data-layout='modal-external']{position:fixed!important;right:auto!important;bottom:auto!important;left:var(--local-rg-left,8px)!important;top:var(--local-rg-top,8px)!important;z-index:2147483646!important;display:none!important;width:38px!important;height:38px!important;box-shadow:0 6px 18px rgba(0,0,0,.36)!important}",
|
||
".local-rg-download-button[data-layout='modal-external'] .local-rg-button-glyph svg{width:22px!important;height:22px!important}",
|
||
".local-rg-diagnostic-anchor{outline:3px solid #31d7ff!important;outline-offset:-3px!important}",
|
||
".local-rg-diagnostic-root{outline:3px solid #ffd43b!important;outline-offset:-6px!important}",
|
||
".local-rg-diagnostic-host{outline:3px dashed #a66cff!important;outline-offset:-9px!important}",
|
||
".local-rg-diagnostic-button{outline:3px solid #ff7a33!important;outline-offset:3px!important}",
|
||
".local-rg-toast-stack{all:initial!important;position:fixed!important;top:18px!important;left:50%!important;z-index:2147483647!important;display:flex!important;width:min(440px,calc(100vw - 28px))!important;flex-direction:column!important;gap:9px!important;transform:translateX(-50%)!important;pointer-events:none!important}",
|
||
".local-rg-toast{all:initial;box-sizing:border-box!important;display:block!important;overflow:hidden!important;border:1px solid rgba(255,255,255,.14)!important;border-radius:14px!important;background:rgba(12,16,23,.96)!important;color:#f5f7fb!important;box-shadow:0 18px 55px rgba(0,0,0,.48)!important;font-family:system-ui,-apple-system,'Segoe UI',sans-serif!important;backdrop-filter:blur(12px)!important;pointer-events:none!important;will-change:transform,opacity,filter!important;transform-origin:50% 0!important}",
|
||
".local-rg-toast.is-entering{animation:localRgToastIn .46s cubic-bezier(.16,1,.3,1)!important}",
|
||
".local-rg-toast.is-leaving{animation:localRgToastOut .3s cubic-bezier(.55,0,1,.45) forwards!important}",
|
||
".local-rg-toast-main{display:flex!important;align-items:center!important;gap:11px!important;padding:12px 13px 10px!important}",
|
||
".local-rg-toast-icon{display:grid!important;width:34px!important;height:34px!important;flex:0 0 34px!important;place-items:center!important;border-radius:10px!important;background:#f04468!important;color:#fff!important;font:900 18px/1 system-ui!important}",
|
||
".local-rg-toast-copy{display:block!important;min-width:0!important;flex:1!important}",
|
||
".local-rg-toast-title,.local-rg-toast-status{display:block!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important}",
|
||
".local-rg-toast-title{color:#f4f7fb!important;font:800 12px/1.3 system-ui!important}",
|
||
".local-rg-toast-status{margin-top:4px!important;color:#95a1b5!important;font:500 11px/1.3 system-ui!important}",
|
||
".local-rg-toast-value{color:#cbd3df!important;font:800 11px/1 system-ui!important}",
|
||
".local-rg-toast-track{height:5px!important;overflow:hidden!important;background:#242d3c!important}",
|
||
".local-rg-toast-bar{height:100%!important;width:0!important;background:linear-gradient(90deg,#159665,#35d596)!important;box-shadow:0 0 10px rgba(53,213,150,.38)!important;transition:width .18s ease!important}",
|
||
".local-rg-toast[data-state='complete'] .local-rg-toast-bar{transition:width .25s linear!important}",
|
||
".local-rg-toast[data-state='complete'] .local-rg-toast-icon,.local-rg-toast[data-state='complete'] .local-rg-toast-bar{background:#20b879!important}",
|
||
".local-rg-toast[data-state='error'] .local-rg-toast-icon,.local-rg-toast[data-state='error'] .local-rg-toast-bar{background:#d53c5d!important}",
|
||
"@keyframes localRgButtonWorking{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-2px) scale(1.06)}}",
|
||
"@keyframes localRgToastIn{0%{opacity:0;filter:blur(7px);transform:translateY(-20px) scale(.94)}65%{opacity:1;filter:blur(0);transform:translateY(2px) scale(1.01)}100%{opacity:1;filter:blur(0);transform:translateY(0) scale(1)}}",
|
||
"@keyframes localRgToastOut{0%{opacity:1;filter:blur(0);transform:translateY(0) scale(1)}100%{opacity:0;filter:blur(6px);transform:translateY(-18px) scale(.96)}}",
|
||
"@media(max-width:700px){.local-rg-download-button{right:6px!important;bottom:6px!important;left:auto!important;width:38px!important;height:38px!important;font-size:21px!important}.local-rg-toast-stack{top:10px!important}}"
|
||
].join("");
|
||
(document.head || document.documentElement).appendChild(style);
|
||
}
|
||
|
||
const BUTTON_ICON_DOWNLOAD = [
|
||
'<span class="local-rg-button-glyph local-rg-icon-download" aria-hidden="true">',
|
||
'<svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M5 21h14"/></svg>',
|
||
'</span>'
|
||
].join("");
|
||
const BUTTON_ICON_CHECK = [
|
||
'<span class="local-rg-button-glyph local-rg-icon-check" aria-hidden="true">',
|
||
'<svg viewBox="0 0 24 24"><path d="m5 12 4.2 4.2L19 6.5"/></svg>',
|
||
'</span>'
|
||
].join("");
|
||
const BUTTON_ICON_REDOWNLOAD = [
|
||
'<span class="local-rg-button-glyph local-rg-icon-redownload" aria-hidden="true">',
|
||
'<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-15-6.7L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 15 6.7L21 16"/><path d="M21 21v-5h-5"/></svg>',
|
||
'</span>'
|
||
].join("");
|
||
const BUTTON_ICON_ERROR = [
|
||
'<span class="local-rg-button-glyph local-rg-icon-error" aria-hidden="true">',
|
||
'<svg viewBox="0 0 24 24"><path d="M12 5v9"/><path d="M12 18h.01"/></svg>',
|
||
'</span>'
|
||
].join("");
|
||
|
||
function setButtonState(button, state) {
|
||
button.dataset.state = state;
|
||
const markup = state === "downloaded"
|
||
? BUTTON_ICON_CHECK + BUTTON_ICON_REDOWNLOAD
|
||
: state === "done"
|
||
? BUTTON_ICON_CHECK
|
||
: state === "error"
|
||
? BUTTON_ICON_ERROR
|
||
: BUTTON_ICON_DOWNLOAD;
|
||
if (button.__localRgIconMarkup !== markup) {
|
||
button.innerHTML = markup;
|
||
button.__localRgIconMarkup = markup;
|
||
}
|
||
const labels = {
|
||
downloaded: "Скачать повторно",
|
||
done: "Скачивание завершено",
|
||
error: "Ошибка загрузки",
|
||
working: "Скачивание",
|
||
idle: "Скачать видео"
|
||
};
|
||
button.setAttribute("aria-label", labels[state] || "Скачать видео");
|
||
}
|
||
|
||
const TOAST_COMPLETE_MS = 5000;
|
||
const TOAST_EXIT_MS = 300;
|
||
const toastEntries = new Map();
|
||
const jobButtons = new Map();
|
||
const pendingJobButtons = new Map();
|
||
const mediaButtons = new Map();
|
||
const downloadedRecords = new Map();
|
||
const externalButtons = new Set();
|
||
const checkedMediaIds = new Set();
|
||
const pendingHistoryIds = new Set();
|
||
const MEDIA_CANDIDATE_SELECTOR = [
|
||
"[data-feed-item-id]",
|
||
"a[href*='/watch/']",
|
||
"a[href*='/ifr/']",
|
||
"video",
|
||
"img[src*='media.redgifs.com']",
|
||
"img[data-src*='media.redgifs.com']",
|
||
"img[data-lazy-src*='media.redgifs.com']"
|
||
].join(", ");
|
||
const ANCHOR_RECONNECT_GRACE_MS = 500;
|
||
let historyCheckTimer = null;
|
||
let externalLayoutFrame = null;
|
||
let externalReconnectTimer = null;
|
||
let lastScanDiagnostics = [];
|
||
const contentDiagnosticEvents = [];
|
||
let diagnosticsEnabled = false;
|
||
|
||
function applyDiagnosticsState(enabled) {
|
||
diagnosticsEnabled = Boolean(enabled);
|
||
if (diagnosticsEnabled) return;
|
||
contentDiagnosticEvents.length = 0;
|
||
lastScanDiagnostics = [];
|
||
const buttons = new Set(externalButtons);
|
||
for (const buttonsForMedia of mediaButtons.values()) {
|
||
for (const button of buttonsForMedia) buttons.add(button);
|
||
}
|
||
for (const button of buttons) {
|
||
if (button) button.__localRgDecisionHistory = [];
|
||
}
|
||
}
|
||
|
||
function recordContentDiagnostic(level, event, details) {
|
||
if (!diagnosticsEnabled) return null;
|
||
const entry = {
|
||
at: new Date().toISOString(),
|
||
level: level || "info",
|
||
event: String(event || "event").slice(0, 120),
|
||
details: details || {}
|
||
};
|
||
contentDiagnosticEvents.push(entry);
|
||
if (contentDiagnosticEvents.length > 100) contentDiagnosticEvents.splice(0, 20);
|
||
try {
|
||
const pending = chrome.runtime.sendMessage({
|
||
action: "diagnosticEvent",
|
||
level: entry.level,
|
||
area: "content",
|
||
event: entry.event,
|
||
details: entry.details
|
||
});
|
||
if (pending && typeof pending.catch === "function") pending.catch(() => {});
|
||
} catch {
|
||
// Local page diagnostics remain available.
|
||
}
|
||
}
|
||
|
||
function diagnosticSafeUrl(value) {
|
||
try {
|
||
const parsed = new URL(String(value || ""), document.baseURI);
|
||
parsed.username = "";
|
||
parsed.password = "";
|
||
parsed.search = "";
|
||
parsed.hash = "";
|
||
return parsed.toString();
|
||
} catch {
|
||
return String(value || "").slice(0, 300);
|
||
}
|
||
}
|
||
|
||
function setButtonLayoutReason(button, code, details) {
|
||
if (!button) return;
|
||
const previous = button.__localRgLayoutReason || null;
|
||
if (!diagnosticsEnabled) {
|
||
button.__localRgLayoutReason = { code };
|
||
button.__localRgDecisionHistory = [];
|
||
return;
|
||
}
|
||
const next = Object.assign({
|
||
code,
|
||
at: new Date().toISOString()
|
||
}, details || {});
|
||
button.__localRgLayoutReason = next;
|
||
if (previous && previous.code === code) return;
|
||
if (!button.__localRgDecisionHistory) button.__localRgDecisionHistory = [];
|
||
const anchor = button.__localRgAnchor;
|
||
const root = button.__localRgRoot;
|
||
const host = button.__localRgScrollHost;
|
||
button.__localRgDecisionHistory.push({
|
||
decision: next,
|
||
previousCode: previous && previous.code || null,
|
||
inlineDisplay: button.style && button.style.display || null,
|
||
anchorRect: diagnosticRect(anchor),
|
||
anchorConnected: !anchor || !("isConnected" in anchor) || Boolean(anchor.isConnected),
|
||
rootConnected: !root || !("isConnected" in root) || Boolean(root.isConnected),
|
||
hostScroll: host ? {
|
||
top: Number(host.scrollTop) || 0,
|
||
left: Number(host.scrollLeft) || 0
|
||
} : null
|
||
});
|
||
if (button.__localRgDecisionHistory.length > 12) {
|
||
button.__localRgDecisionHistory.splice(0, button.__localRgDecisionHistory.length - 12);
|
||
}
|
||
}
|
||
|
||
function isGeneralFeedPage() {
|
||
return !/\/(?:users|watch|ifr)\//i.test(String(location.pathname || ""));
|
||
}
|
||
|
||
function isPageChromeRoot(root) {
|
||
if (!root || typeof root.closest !== "function") return false;
|
||
return Boolean(root.closest([
|
||
"header",
|
||
"nav",
|
||
"aside",
|
||
"[role='banner']",
|
||
"[role='navigation']",
|
||
"[role='complementary']",
|
||
"[class*='sidebar' i]",
|
||
"[class*='side-menu' i]"
|
||
].join(", ")));
|
||
}
|
||
|
||
const MODAL_SELECTOR = [
|
||
"[aria-modal='true']",
|
||
"[role='dialog']",
|
||
"[data-testid*='fullscreen' i]",
|
||
"[data-testid*='full-screen' i]",
|
||
"[class*='fullscreen' i]",
|
||
"[class*='full-screen' i]"
|
||
].join(", ");
|
||
|
||
function modalContainerFor(element) {
|
||
if (!element) return null;
|
||
if (document.fullscreenElement) {
|
||
let current = element;
|
||
while (current) {
|
||
if (current === document.fullscreenElement) return document.fullscreenElement;
|
||
current = current.parentElement;
|
||
}
|
||
}
|
||
if (typeof element.closest === "function") {
|
||
try {
|
||
const matched = element.closest(MODAL_SELECTOR);
|
||
if (matched) return matched;
|
||
} catch {
|
||
// Continue with the geometric fallback below.
|
||
}
|
||
}
|
||
if (typeof globalThis.getComputedStyle !== "function") return null;
|
||
const viewportWidth = Number(globalThis.innerWidth) || 0;
|
||
const viewportHeight = Number(globalThis.innerHeight) || 0;
|
||
let current = element;
|
||
while (current && current !== document.body && current !== document.documentElement) {
|
||
if (typeof current.getBoundingClientRect === "function") {
|
||
const rect = current.getBoundingClientRect();
|
||
const style = globalThis.getComputedStyle(current);
|
||
if (style && style.position === "fixed"
|
||
&& rect.width >= viewportWidth * 0.6
|
||
&& rect.height >= viewportHeight * 0.6) {
|
||
return current;
|
||
}
|
||
}
|
||
current = current.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function visibleAreaFor(element) {
|
||
if (!element || ("isConnected" in element && !element.isConnected)) return 0;
|
||
if (typeof element.getBoundingClientRect !== "function") return 0;
|
||
const rect = element.getBoundingClientRect();
|
||
const viewportWidth = Number(globalThis.innerWidth) || 0;
|
||
const viewportHeight = Number(globalThis.innerHeight) || 0;
|
||
if (!rect || rect.width <= 0 || rect.height <= 0) return 0;
|
||
return Math.max(0, Math.min(viewportWidth, rect.right) - Math.max(0, rect.left))
|
||
* Math.max(0, Math.min(viewportHeight, rect.bottom) - Math.max(0, rect.top));
|
||
}
|
||
|
||
function usableLayoutRect(rect) {
|
||
return Boolean(rect && rect.width > 0 && rect.height > 0);
|
||
}
|
||
|
||
function rectOverlapRatio(first, second) {
|
||
if (!usableLayoutRect(first) || !usableLayoutRect(second)) return 0;
|
||
const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left));
|
||
const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top));
|
||
const smallerArea = Math.min(first.width * first.height, second.width * second.height);
|
||
return smallerArea > 0 ? (width * height) / smallerArea : 0;
|
||
}
|
||
|
||
function rememberAnchorGeometry(button, anchorRect, rootRect) {
|
||
if (!button || !usableLayoutRect(anchorRect) || !usableLayoutRect(rootRect)) return;
|
||
button.__localRgAnchorGeometry = {
|
||
left: anchorRect.left - rootRect.left,
|
||
top: anchorRect.top - rootRect.top,
|
||
width: anchorRect.width,
|
||
height: anchorRect.height
|
||
};
|
||
}
|
||
|
||
function anchorRectFromRoot(button, rootRect) {
|
||
const geometry = button && button.__localRgAnchorGeometry;
|
||
if (!geometry || !usableLayoutRect(rootRect)) return null;
|
||
const left = rootRect.left + geometry.left;
|
||
const top = rootRect.top + geometry.top;
|
||
return {
|
||
left,
|
||
right: left + geometry.width,
|
||
top,
|
||
bottom: top + geometry.height,
|
||
width: geometry.width,
|
||
height: geometry.height
|
||
};
|
||
}
|
||
|
||
function setStyleProperty(element, name, value) {
|
||
if (element && element.style && typeof element.style.setProperty === "function") {
|
||
element.style.setProperty(name, value, "important");
|
||
} else if (element && element.style) {
|
||
element.style[name] = value;
|
||
}
|
||
}
|
||
|
||
function pageTopBoundary(viewportWidth, viewportHeight) {
|
||
let boundary = 0;
|
||
if (!document.querySelectorAll) return 8;
|
||
const selectors = [
|
||
"header",
|
||
"[role='banner']",
|
||
"[class*='site-header' i]",
|
||
"[class*='top-header' i]",
|
||
"[class*='topbar' i]"
|
||
].join(", ");
|
||
for (const element of Array.from(document.querySelectorAll(selectors))) {
|
||
if (!element || typeof element.getBoundingClientRect !== "function") continue;
|
||
const rect = element.getBoundingClientRect();
|
||
if (!rect || rect.width < viewportWidth * 0.45 || rect.height <= 0 || rect.height > viewportHeight * 0.35) continue;
|
||
if (rect.top <= 8 && rect.bottom > 0) boundary = Math.max(boundary, rect.bottom);
|
||
}
|
||
return Math.max(8, Math.min(boundary + (boundary ? 8 : 0), viewportHeight));
|
||
}
|
||
|
||
function isDocumentScrollHost(element) {
|
||
return !element
|
||
|| element === document.body
|
||
|| element === document.documentElement
|
||
|| element === document.scrollingElement;
|
||
}
|
||
|
||
function scrollHostFor(element) {
|
||
if (!element || typeof globalThis.getComputedStyle !== "function") {
|
||
return document.scrollingElement || document.body || document.documentElement;
|
||
}
|
||
let current = element.parentElement;
|
||
while (current && current !== document.body && current !== document.documentElement) {
|
||
try {
|
||
const style = globalThis.getComputedStyle(current);
|
||
const overflow = String(style && (style.overflowY || style.overflow) || "");
|
||
const scrollableOverflow = /auto|scroll|overlay/i.test(overflow);
|
||
const hasScrollRange = Number(current.scrollHeight) > Number(current.clientHeight) + 1;
|
||
if ((scrollableOverflow && hasScrollRange) || Number(current.scrollTop) > 0) return current;
|
||
} catch {
|
||
// Continue towards the document scroll host.
|
||
}
|
||
current = current.parentElement;
|
||
}
|
||
return document.scrollingElement || document.body || document.documentElement;
|
||
}
|
||
|
||
function feedPositionInHost(button, left, top) {
|
||
const host = button.__localRgScrollHost;
|
||
if (isDocumentScrollHost(host)) {
|
||
return {
|
||
left: left + (Number(globalThis.scrollX) || Number(globalThis.pageXOffset) || 0),
|
||
top: top + (Number(globalThis.scrollY) || Number(globalThis.pageYOffset) || 0)
|
||
};
|
||
}
|
||
if (!host || typeof host.getBoundingClientRect !== "function") return { left, top };
|
||
const hostRect = host.getBoundingClientRect();
|
||
return {
|
||
left: left - hostRect.left + Number(host.scrollLeft || 0) - Number(host.clientLeft || 0),
|
||
top: top - hostRect.top + Number(host.scrollTop || 0) - Number(host.clientTop || 0)
|
||
};
|
||
}
|
||
|
||
function modalButtonPosition(overlay, mediaRect, size, viewportWidth, viewportHeight) {
|
||
const fallback = {
|
||
left: Math.max(8, Math.min(mediaRect.right - size - 18, viewportWidth - size - 8)),
|
||
top: Math.max(8, Math.min(mediaRect.bottom - size - 64, viewportHeight - size - 8))
|
||
};
|
||
if (!overlay || typeof overlay.querySelectorAll !== "function") return fallback;
|
||
const controls = [];
|
||
for (const element of Array.from(overlay.querySelectorAll("button, [role='button']"))) {
|
||
if (!element || element.classList && element.classList.contains("local-rg-download-button")) continue;
|
||
if (typeof element.getBoundingClientRect !== "function") continue;
|
||
const rect = element.getBoundingClientRect();
|
||
if (!rect || rect.width < 18 || rect.height < 18 || rect.width > 110 || rect.height > 110) continue;
|
||
const centerX = (rect.left + rect.right) / 2;
|
||
if (centerX < mediaRect.right - 190 || centerX > mediaRect.right + 24) continue;
|
||
if (rect.bottom <= mediaRect.top || rect.top >= mediaRect.bottom - 44) continue;
|
||
controls.push({ rect, centerX });
|
||
}
|
||
if (!controls.length) return fallback;
|
||
const clusters = [];
|
||
for (const control of controls) {
|
||
let cluster = clusters.find((item) => Math.abs(item.centerX - control.centerX) <= 26);
|
||
if (!cluster) {
|
||
cluster = { centerX: control.centerX, controls: [] };
|
||
clusters.push(cluster);
|
||
}
|
||
cluster.controls.push(control);
|
||
cluster.centerX = cluster.controls.reduce((sum, item) => sum + item.centerX, 0) / cluster.controls.length;
|
||
}
|
||
clusters.sort((left, right) => right.controls.length - left.controls.length || right.centerX - left.centerX);
|
||
const column = clusters[0];
|
||
if (!column || column.controls.length < 2) return fallback;
|
||
const lastBottom = Math.max.apply(null, column.controls.map((item) => item.rect.bottom));
|
||
const safeBottom = Math.min(mediaRect.bottom - 58, viewportHeight - 8);
|
||
let top = lastBottom + 12;
|
||
if (top + size > safeBottom) top = safeBottom - size;
|
||
if (top < mediaRect.top + 8) return fallback;
|
||
return {
|
||
left: Math.max(8, Math.min(column.centerX - size / 2, viewportWidth - size - 8)),
|
||
top: Math.max(8, Math.min(top, viewportHeight - size - 8))
|
||
};
|
||
}
|
||
|
||
function scheduleReconnectCheck() {
|
||
if (externalReconnectTimer != null) return;
|
||
externalReconnectTimer = setTimeout(() => {
|
||
externalReconnectTimer = null;
|
||
scheduleExternalLayout();
|
||
}, ANCHOR_RECONNECT_GRACE_MS + 40);
|
||
}
|
||
|
||
function preserveWhileReconnecting(button, timestamp) {
|
||
if (!button.__localRgAnchorMissingSince) {
|
||
button.__localRgAnchorMissingSince = timestamp;
|
||
scheduleReconnectCheck();
|
||
}
|
||
return timestamp - button.__localRgAnchorMissingSince < ANCHOR_RECONNECT_GRACE_MS;
|
||
}
|
||
|
||
function layoutExternalButtons() {
|
||
externalLayoutFrame = null;
|
||
const viewportWidth = Number(globalThis.innerWidth) || (document.documentElement && document.documentElement.clientWidth) || 0;
|
||
const viewportHeight = Number(globalThis.innerHeight) || (document.documentElement && document.documentElement.clientHeight) || 0;
|
||
const topBoundary = pageTopBoundary(viewportWidth, viewportHeight);
|
||
const size = viewportWidth <= 700 ? 38 : 42;
|
||
const gap = 10;
|
||
const candidates = [];
|
||
const timestamp = Date.now();
|
||
for (const button of Array.from(externalButtons)) {
|
||
const root = button.__localRgRoot;
|
||
if (!root || ("isConnected" in root && !root.isConnected)) {
|
||
if (preserveWhileReconnecting(button, timestamp)) {
|
||
setButtonLayoutReason(button, "root-reconnecting");
|
||
continue;
|
||
}
|
||
disposeButton(button, "root-disconnected");
|
||
continue;
|
||
}
|
||
const anchor = button.__localRgAnchor || root;
|
||
const overlay = modalContainerFor(anchor) || modalContainerFor(root);
|
||
const rootRect = typeof root.getBoundingClientRect === "function" ? root.getBoundingClientRect() : null;
|
||
const anchorConnected = anchor && (!("isConnected" in anchor) || anchor.isConnected);
|
||
const anchorRect = anchorConnected && typeof anchor.getBoundingClientRect === "function"
|
||
? anchor.getBoundingClientRect()
|
||
: null;
|
||
const anchorMatchesRoot = overlay
|
||
|| !usableLayoutRect(rootRect)
|
||
|| (usableLayoutRect(anchorRect) && rectOverlapRatio(anchorRect, rootRect) >= 0.35);
|
||
let rect = anchorMatchesRoot && usableLayoutRect(anchorRect) ? anchorRect : null;
|
||
let fallbackReason = null;
|
||
if (rect && usableLayoutRect(rootRect)) {
|
||
rememberAnchorGeometry(button, rect, rootRect);
|
||
} else if (overlay && usableLayoutRect(rootRect)) {
|
||
rect = rootRect;
|
||
fallbackReason = "visible-from-modal-root";
|
||
} else {
|
||
rect = anchorRectFromRoot(button, rootRect);
|
||
fallbackReason = !anchorConnected
|
||
? "visible-from-root-anchor-disconnected"
|
||
: usableLayoutRect(anchorRect)
|
||
? "visible-from-root-anchor-outside-card"
|
||
: "visible-from-root-anchor-zero-size";
|
||
}
|
||
if (!usableLayoutRect(rect)) {
|
||
if (preserveWhileReconnecting(button, timestamp)) {
|
||
setButtonLayoutReason(button, anchorConnected ? "anchor-zero-size-reconnecting" : "anchor-reconnecting");
|
||
continue;
|
||
}
|
||
setStyleProperty(button, "display", "none");
|
||
setButtonLayoutReason(button, anchorConnected ? "anchor-zero-size" : "anchor-disconnected", {
|
||
width: anchorRect && anchorRect.width,
|
||
height: anchorRect && anchorRect.height
|
||
});
|
||
continue;
|
||
}
|
||
button.__localRgAnchorMissingSince = 0;
|
||
if (rect.bottom < 0 || rect.top > viewportHeight) {
|
||
setStyleProperty(button, "display", "none");
|
||
setButtonLayoutReason(button, "anchor-outside-viewport", { top: rect.top, bottom: rect.bottom });
|
||
continue;
|
||
}
|
||
const visibleLeft = Math.max(0, rect.left);
|
||
const visibleRight = Math.min(viewportWidth, rect.right);
|
||
const visibleTop = Math.max(0, rect.top);
|
||
const visibleBottom = Math.min(viewportHeight, rect.bottom);
|
||
const visibleWidth = visibleRight - visibleLeft;
|
||
const visibleHeight = visibleBottom - visibleTop;
|
||
if (visibleWidth <= 0 || visibleHeight <= 0) {
|
||
setStyleProperty(button, "display", "none");
|
||
setButtonLayoutReason(button, "anchor-no-visible-area", { visibleWidth, visibleHeight });
|
||
continue;
|
||
}
|
||
candidates.push({ button, rect, visibleTop, visibleBottom, overlay, fallbackReason });
|
||
}
|
||
|
||
const overlayCandidates = candidates.filter((candidate) => candidate.overlay);
|
||
let layoutCandidates = candidates;
|
||
if (overlayCandidates.length) {
|
||
overlayCandidates.sort((left, right) => {
|
||
return visibleAreaFor(right.overlay)
|
||
- visibleAreaFor(left.overlay)
|
||
|| (right.visibleBottom - right.visibleTop) - (left.visibleBottom - left.visibleTop);
|
||
});
|
||
layoutCandidates = [overlayCandidates[0]];
|
||
const activeButton = layoutCandidates[0].button;
|
||
for (const button of externalButtons) {
|
||
if (button !== activeButton) {
|
||
setStyleProperty(button, "display", "none");
|
||
setButtonLayoutReason(button, "suppressed-by-fullscreen");
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const candidate of layoutCandidates) {
|
||
const button = candidate.button;
|
||
const rect = candidate.rect;
|
||
if (candidate.overlay) {
|
||
const modalSize = viewportWidth <= 700 ? 36 : 38;
|
||
const position = modalButtonPosition(candidate.overlay, rect, modalSize, viewportWidth, viewportHeight);
|
||
setStyleProperty(button, "display", "flex");
|
||
setStyleProperty(button, "--local-rg-left", Math.round(position.left) + "px");
|
||
setStyleProperty(button, "--local-rg-top", Math.round(position.top) + "px");
|
||
setButtonLayoutReason(button, "visible-fullscreen", { left: position.left, top: position.top });
|
||
continue;
|
||
}
|
||
let left = rect.right + gap;
|
||
let top = Math.min(rect.bottom - size - gap, viewportHeight - size - 8);
|
||
if (left + size > viewportWidth - 8) {
|
||
left = Math.max(8, Math.min(rect.left + gap, viewportWidth - size - 8));
|
||
top = Math.min(rect.top + gap, viewportHeight - size - 8);
|
||
}
|
||
if (top < topBoundary) {
|
||
setStyleProperty(button, "display", "none");
|
||
setButtonLayoutReason(button, "hidden-under-page-header", { intendedTop: top, topBoundary });
|
||
continue;
|
||
}
|
||
setStyleProperty(button, "display", "flex");
|
||
const position = feedPositionInHost(button, left, top);
|
||
setStyleProperty(button, "--local-rg-left", Math.round(position.left) + "px");
|
||
setStyleProperty(button, "--local-rg-top", Math.round(position.top) + "px");
|
||
setButtonLayoutReason(button, candidate.fallbackReason || "visible-feed", {
|
||
left: position.left,
|
||
top: position.top,
|
||
topBoundary
|
||
});
|
||
}
|
||
}
|
||
|
||
function scheduleExternalLayout() {
|
||
if (externalLayoutFrame != null) return;
|
||
const schedule = typeof globalThis.requestAnimationFrame === "function"
|
||
? globalThis.requestAnimationFrame.bind(globalThis)
|
||
: (callback) => setTimeout(callback, 16);
|
||
let ranSynchronously = false;
|
||
const frame = schedule(() => {
|
||
ranSynchronously = true;
|
||
externalLayoutFrame = null;
|
||
layoutExternalButtons();
|
||
});
|
||
if (!ranSynchronously) externalLayoutFrame = frame;
|
||
}
|
||
|
||
function scheduleFeedRefresh() {
|
||
scheduleExternalLayout();
|
||
}
|
||
|
||
function configureButtonLayout(root, button, anchor) {
|
||
const overlay = modalContainerFor(anchor || root) || modalContainerFor(root);
|
||
const external = isGeneralFeedPage() || Boolean(overlay);
|
||
const wasExternal = externalButtons.has(button);
|
||
button.__localRgRoot = root;
|
||
button.__localRgAnchor = anchor || root;
|
||
button.__localRgOverlay = overlay;
|
||
button.__localRgAnchorMissingSince = 0;
|
||
button.dataset.layout = overlay ? "modal-external" : (external ? "feed-external" : "tile");
|
||
if (external) {
|
||
externalButtons.add(button);
|
||
if (!wasExternal) setStyleProperty(button, "display", "none");
|
||
const host = overlay
|
||
? (document.body || document.documentElement)
|
||
: scrollHostFor(anchor || root);
|
||
button.__localRgScrollHost = host;
|
||
if (!overlay && host && !isDocumentScrollHost(host)) {
|
||
host.classList.add("local-rg-scroll-host");
|
||
if (!host.__localRgScrollListener && typeof host.addEventListener === "function") {
|
||
host.__localRgScrollListener = true;
|
||
host.addEventListener("scroll", scheduleFeedRefresh, { passive: true });
|
||
}
|
||
}
|
||
if (button.parentElement !== host) host.appendChild(button);
|
||
scheduleExternalLayout();
|
||
} else {
|
||
externalButtons.delete(button);
|
||
button.__localRgScrollHost = null;
|
||
if (button.parentElement !== root) root.appendChild(button);
|
||
}
|
||
}
|
||
|
||
function normalizeMediaKey(value) {
|
||
const key = String(value || "").trim().toLowerCase();
|
||
return /^[a-z0-9_-]{2,160}$/i.test(key) ? key : null;
|
||
}
|
||
|
||
function diagnosticRect(element) {
|
||
if (!element || typeof element.getBoundingClientRect !== "function") return null;
|
||
try {
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
left: Math.round(rect.left * 10) / 10,
|
||
right: Math.round(rect.right * 10) / 10,
|
||
top: Math.round(rect.top * 10) / 10,
|
||
bottom: Math.round(rect.bottom * 10) / 10,
|
||
width: Math.round(rect.width * 10) / 10,
|
||
height: Math.round(rect.height * 10) / 10
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function diagnosticElement(element) {
|
||
if (!element) return null;
|
||
let style = null;
|
||
try {
|
||
const computed = typeof globalThis.getComputedStyle === "function"
|
||
? globalThis.getComputedStyle(element)
|
||
: null;
|
||
if (computed) {
|
||
style = {
|
||
display: computed.display || null,
|
||
visibility: computed.visibility || null,
|
||
opacity: computed.opacity || null,
|
||
position: computed.position || null,
|
||
overflow: computed.overflow || null,
|
||
overflowX: computed.overflowX || null,
|
||
overflowY: computed.overflowY || null,
|
||
zIndex: computed.zIndex || null,
|
||
transform: computed.transform || null
|
||
};
|
||
}
|
||
} catch {
|
||
// The remaining element data is still useful.
|
||
}
|
||
const get = (name) => {
|
||
try {
|
||
return element.getAttribute && element.getAttribute(name);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
return {
|
||
tag: String(element.tagName || "").toLowerCase() || null,
|
||
id: String(element.id || get("id") || "").slice(0, 160) || null,
|
||
className: String(get("class") || element.className || "").slice(0, 500) || null,
|
||
feedItemId: get("data-feed-item-id"),
|
||
role: get("role"),
|
||
ariaModal: get("aria-modal"),
|
||
isConnected: !("isConnected" in element) || Boolean(element.isConnected),
|
||
rect: diagnosticRect(element),
|
||
scroll: {
|
||
top: Number(element.scrollTop) || 0,
|
||
left: Number(element.scrollLeft) || 0,
|
||
height: Number(element.scrollHeight) || 0,
|
||
width: Number(element.scrollWidth) || 0,
|
||
clientHeight: Number(element.clientHeight) || 0,
|
||
clientWidth: Number(element.clientWidth) || 0
|
||
},
|
||
style
|
||
};
|
||
}
|
||
|
||
function diagnosticAncestors(element) {
|
||
const ancestors = [];
|
||
let current = element;
|
||
for (let depth = 0; current && depth < 14; depth += 1) {
|
||
ancestors.push(diagnosticElement(current));
|
||
current = current.parentElement;
|
||
}
|
||
return ancestors;
|
||
}
|
||
|
||
function diagnosticButton(button) {
|
||
const root = button && button.__localRgRoot;
|
||
const anchor = button && button.__localRgAnchor;
|
||
const host = button && button.__localRgScrollHost;
|
||
const overlay = button && (button.__localRgOverlay || modalContainerFor(anchor) || modalContainerFor(root));
|
||
return {
|
||
mediaId: button && button.__localRgMediaKey || null,
|
||
title: button && button.__localRedgifsItem && button.__localRedgifsItem.title || null,
|
||
layout: button && button.dataset && button.dataset.layout || null,
|
||
state: button && button.dataset && button.dataset.state || null,
|
||
inlineDisplay: button && button.style && button.style.display || null,
|
||
cssLeft: button && button.style && button.style["--local-rg-left"] || null,
|
||
cssTop: button && button.style && button.style["--local-rg-top"] || null,
|
||
lastLayoutDecision: button && button.__localRgLayoutReason || null,
|
||
decisionHistory: button && button.__localRgDecisionHistory || [],
|
||
button: diagnosticElement(button),
|
||
root: diagnosticElement(root),
|
||
anchor: diagnosticElement(anchor),
|
||
scrollHost: diagnosticElement(host),
|
||
parent: diagnosticElement(button && button.parentElement),
|
||
overlay: diagnosticElement(overlay),
|
||
anchorAncestors: diagnosticAncestors(anchor)
|
||
};
|
||
}
|
||
|
||
function capturePageDiagnostics() {
|
||
ensureButtonStyles();
|
||
const buttonSet = new Set(externalButtons);
|
||
for (const buttonsForMedia of mediaButtons.values()) {
|
||
for (const button of buttonsForMedia) buttonSet.add(button);
|
||
}
|
||
const buttons = Array.from(buttonSet);
|
||
const media = Array.from(document.querySelectorAll
|
||
? document.querySelectorAll("video, img[src], img[data-src], img[data-lazy-src]")
|
||
: []).slice(0, 300).map((element) => {
|
||
const root = closestCard(element);
|
||
const host = scrollHostFor(element);
|
||
return {
|
||
element: diagnosticElement(element),
|
||
posterId: element.tagName === "IMG"
|
||
? idFromPosterUrl(element.currentSrc || element.getAttribute("src") || element.getAttribute("data-src") || element.getAttribute("data-lazy-src"))
|
||
: idFromPosterUrl(element.poster || element.getAttribute && element.getAttribute("poster")),
|
||
root: diagnosticElement(root),
|
||
scrollHost: diagnosticElement(host),
|
||
overlay: diagnosticElement(modalContainerFor(element) || modalContainerFor(root)),
|
||
ancestors: diagnosticAncestors(element)
|
||
};
|
||
});
|
||
for (const button of buttons) {
|
||
const root = button.__localRgRoot;
|
||
const anchor = button.__localRgAnchor;
|
||
const host = button.__localRgScrollHost;
|
||
if (button.classList) button.classList.add("local-rg-diagnostic-button");
|
||
if (root && root.classList) root.classList.add("local-rg-diagnostic-root");
|
||
if (anchor && anchor.classList) anchor.classList.add("local-rg-diagnostic-anchor");
|
||
if (host && host.classList && !isDocumentScrollHost(host)) host.classList.add("local-rg-diagnostic-host");
|
||
}
|
||
setTimeout(() => {
|
||
for (const element of Array.from(document.querySelectorAll ? document.querySelectorAll(
|
||
".local-rg-diagnostic-button,.local-rg-diagnostic-root,.local-rg-diagnostic-anchor,.local-rg-diagnostic-host"
|
||
) : [])) {
|
||
if (!element.classList) continue;
|
||
element.classList.remove("local-rg-diagnostic-button");
|
||
element.classList.remove("local-rg-diagnostic-root");
|
||
element.classList.remove("local-rg-diagnostic-anchor");
|
||
element.classList.remove("local-rg-diagnostic-host");
|
||
}
|
||
}, 30000);
|
||
return {
|
||
generatedAt: new Date().toISOString(),
|
||
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null,
|
||
page: {
|
||
url: diagnosticSafeUrl(location.href),
|
||
title: document.title || null,
|
||
readyState: document.readyState || null,
|
||
visibilityState: document.visibilityState || null,
|
||
online: typeof navigator !== "undefined" ? navigator.onLine : null,
|
||
viewport: { width: Number(globalThis.innerWidth) || 0, height: Number(globalThis.innerHeight) || 0 },
|
||
windowScroll: {
|
||
x: Number(globalThis.scrollX) || Number(globalThis.pageXOffset) || 0,
|
||
y: Number(globalThis.scrollY) || Number(globalThis.pageYOffset) || 0
|
||
},
|
||
topBoundary: pageTopBoundary(Number(globalThis.innerWidth) || 0, Number(globalThis.innerHeight) || 0),
|
||
fullscreenElement: diagnosticElement(document.fullscreenElement)
|
||
},
|
||
scanner: {
|
||
diagnosticsEnabled,
|
||
trackedButtons: buttons.length,
|
||
trackedMediaIds: mediaButtons.size,
|
||
externalButtons: externalButtons.size,
|
||
pendingDecorationRoots: pendingDecorationRoots.size
|
||
},
|
||
legend: {
|
||
cyan: "anchor (video/poster)",
|
||
yellow: "card root",
|
||
purpleDashed: "scroll host",
|
||
orange: "download button"
|
||
},
|
||
buttons: buttons.map(diagnosticButton),
|
||
media,
|
||
lastScanDiagnostics,
|
||
contentEvents: contentDiagnosticEvents.slice(),
|
||
resourceTiming: typeof performance !== "undefined" && performance.getEntriesByType
|
||
? performance.getEntriesByType("resource").slice(-150).map((entry) => ({
|
||
url: diagnosticSafeUrl(entry.name),
|
||
initiatorType: entry.initiatorType || null,
|
||
durationMs: Math.round(Number(entry.duration) * 10) / 10,
|
||
transferSize: Number(entry.transferSize) || 0,
|
||
encodedBodySize: Number(entry.encodedBodySize) || 0,
|
||
decodedBodySize: Number(entry.decodedBodySize) || 0
|
||
}))
|
||
: []
|
||
};
|
||
}
|
||
|
||
function findReusableExternalButton(root, item, anchor) {
|
||
const key = normalizeMediaKey(item && item.id);
|
||
const buttons = key && mediaButtons.get(key);
|
||
if (!buttons) return null;
|
||
for (const button of Array.from(buttons)) {
|
||
if (!button) {
|
||
buttons.delete(button);
|
||
continue;
|
||
}
|
||
const previousRoot = button.__localRgRoot;
|
||
if (!previousRoot) {
|
||
disposeButton(button, "reusable-without-root");
|
||
continue;
|
||
}
|
||
const sameRoot = previousRoot === root;
|
||
const previousConnected = previousRoot
|
||
&& (!("isConnected" in previousRoot) || previousRoot.isConnected);
|
||
const nextVisible = Math.max(
|
||
visibleAreaFor(root),
|
||
visibleAreaFor(anchor || root)
|
||
) > 0;
|
||
const adopt = sameRoot
|
||
|| (!previousConnected && nextVisible);
|
||
if (!adopt) continue;
|
||
if (previousRoot && previousRoot !== root && previousRoot.__localRgDownloadButton === button) {
|
||
previousRoot.__localRgDownloadButton = null;
|
||
}
|
||
root.__localRgDownloadButton = button;
|
||
return { button, adopt: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function setButtonReadyState(button) {
|
||
if (!button) return;
|
||
if (button.__localRgDownloadedRecord) {
|
||
button.title = "Уже скачано · нажмите, чтобы скачать ещё раз";
|
||
setButtonState(button, "downloaded", "✓");
|
||
} else {
|
||
button.title = "Скачать видео в лучшем доступном качестве";
|
||
setButtonState(button, "idle", "⇩");
|
||
}
|
||
}
|
||
|
||
function markButtonDownloaded(button, record) {
|
||
if (!button) return;
|
||
button.__localRgDownloadedRecord = record || { downloadedAt: Date.now() };
|
||
if (!button.disabled) setButtonReadyState(button);
|
||
}
|
||
|
||
function markMediaDownloaded(mediaId, record) {
|
||
const key = normalizeMediaKey(mediaId);
|
||
if (!key) return;
|
||
const value = record || { downloadedAt: Date.now() };
|
||
downloadedRecords.set(key, value);
|
||
checkedMediaIds.add(key);
|
||
const buttons = mediaButtons.get(key);
|
||
if (!buttons) return;
|
||
for (const button of buttons) {
|
||
if (button.__localRgMediaKey === key) markButtonDownloaded(button, value);
|
||
}
|
||
}
|
||
|
||
async function flushHistoryChecks() {
|
||
historyCheckTimer = null;
|
||
const ids = Array.from(pendingHistoryIds).slice(0, 500);
|
||
for (const id of ids) pendingHistoryIds.delete(id);
|
||
if (!ids.length) return;
|
||
try {
|
||
const response = await chrome.runtime.sendMessage({ action: "getDownloadedMedia", ids });
|
||
if (!response || !response.ok) throw new Error("История загрузок недоступна");
|
||
const records = response.downloaded || {};
|
||
for (const id of ids) {
|
||
checkedMediaIds.add(id);
|
||
if (records[id]) markMediaDownloaded(id, records[id]);
|
||
}
|
||
} catch (error) {
|
||
recordContentDiagnostic("warn", "download-history-check-failed", {
|
||
idCount: ids.length,
|
||
name: error && error.name,
|
||
message: error && error.message
|
||
});
|
||
for (const id of ids) pendingHistoryIds.add(id);
|
||
}
|
||
}
|
||
|
||
function queueHistoryCheck(mediaId) {
|
||
const key = normalizeMediaKey(mediaId);
|
||
if (!key || checkedMediaIds.has(key) || downloadedRecords.has(key)) return;
|
||
pendingHistoryIds.add(key);
|
||
clearTimeout(historyCheckTimer);
|
||
historyCheckTimer = setTimeout(flushHistoryChecks, 80);
|
||
}
|
||
|
||
function registerMediaButton(button, item) {
|
||
const key = normalizeMediaKey(item && item.id);
|
||
const previousKey = button.__localRgMediaKey || null;
|
||
if (previousKey && previousKey !== key) {
|
||
const previousButtons = mediaButtons.get(previousKey);
|
||
if (previousButtons) previousButtons.delete(button);
|
||
button.__localRgDownloadedRecord = null;
|
||
}
|
||
button.__localRgMediaKey = key;
|
||
if (!key) {
|
||
if (!button.disabled) setButtonReadyState(button);
|
||
return;
|
||
}
|
||
if (!mediaButtons.has(key)) mediaButtons.set(key, new Set());
|
||
mediaButtons.get(key).add(button);
|
||
if (downloadedRecords.has(key)) {
|
||
markButtonDownloaded(button, downloadedRecords.get(key));
|
||
} else {
|
||
if (previousKey !== key && !button.disabled) setButtonReadyState(button);
|
||
queueHistoryCheck(key);
|
||
}
|
||
}
|
||
|
||
function disposeButton(button, reason) {
|
||
if (!button) return;
|
||
const root = button.__localRgRoot;
|
||
const mediaKey = button.__localRgMediaKey;
|
||
externalButtons.delete(button);
|
||
if (mediaKey) {
|
||
const buttons = mediaButtons.get(mediaKey);
|
||
if (buttons) {
|
||
buttons.delete(button);
|
||
if (!buttons.size) mediaButtons.delete(mediaKey);
|
||
}
|
||
}
|
||
if (root && root.__localRgDownloadButton === button) {
|
||
root.__localRgDownloadButton = null;
|
||
}
|
||
if (!button.__localRgJobId && button.__localRgResetTimer) {
|
||
clearTimeout(button.__localRgResetTimer);
|
||
button.__localRgResetTimer = null;
|
||
}
|
||
if (button.remove) button.remove();
|
||
button.__localRgRoot = null;
|
||
button.__localRgAnchor = null;
|
||
button.__localRgOverlay = null;
|
||
button.__localRgScrollHost = null;
|
||
button.__localRgAnchorGeometry = null;
|
||
button.__localRgAnchorMissingSince = 0;
|
||
button.__localRgMediaKey = null;
|
||
button.__localRgDownloadedRecord = null;
|
||
button.__localRedgifsItem = null;
|
||
button.__localRgLayoutReason = {
|
||
code: reason || "disposed",
|
||
at: new Date().toISOString()
|
||
};
|
||
button.__localRgDecisionHistory = [];
|
||
}
|
||
|
||
function pruneDetachedButtons() {
|
||
for (const buttons of Array.from(mediaButtons.values())) {
|
||
for (const button of Array.from(buttons)) {
|
||
if (externalButtons.has(button)) continue;
|
||
const root = button && button.__localRgRoot;
|
||
if (!root || ("isConnected" in root && !root.isConnected)) {
|
||
disposeButton(button, "pruned-disconnected-root");
|
||
}
|
||
}
|
||
}
|
||
for (const button of Array.from(externalButtons)) {
|
||
if (!button) {
|
||
externalButtons.delete(button);
|
||
continue;
|
||
}
|
||
const root = button.__localRgRoot;
|
||
if (!root || ("isConnected" in root && !root.isConnected)) {
|
||
scheduleExternalLayout();
|
||
}
|
||
}
|
||
}
|
||
|
||
function animateToastEntrance(root) {
|
||
if (!root) return;
|
||
if (typeof root.animate === "function") {
|
||
try {
|
||
root.animate([
|
||
{ opacity: 0, transform: "translateY(-28px) scale(.9)", filter: "blur(9px)" },
|
||
{ offset: 0.72, opacity: 1, transform: "translateY(4px) scale(1.015)", filter: "blur(0)" },
|
||
{ opacity: 1, transform: "translateY(0) scale(1)", filter: "blur(0)" }
|
||
], {
|
||
duration: 460,
|
||
easing: "cubic-bezier(.16,1,.3,1)"
|
||
});
|
||
return;
|
||
} catch {
|
||
// CSS fallback below.
|
||
}
|
||
}
|
||
root.classList.add("is-entering");
|
||
setTimeout(() => root.classList.remove("is-entering"), 470);
|
||
}
|
||
|
||
function ensureToastStack() {
|
||
ensureButtonStyles();
|
||
let stack = document.getElementById("local-rg-toast-stack");
|
||
if (stack) return stack;
|
||
stack = document.createElement("div");
|
||
stack.id = "local-rg-toast-stack";
|
||
stack.className = "local-rg-toast-stack";
|
||
(document.body || document.documentElement).appendChild(stack);
|
||
return stack;
|
||
}
|
||
|
||
function finishToastRemoval(jobId, entry) {
|
||
if (toastEntries.get(jobId) !== entry) return;
|
||
if (entry.timer) clearTimeout(entry.timer);
|
||
if (entry.removeTimer) clearTimeout(entry.removeTimer);
|
||
if (entry.countdownInterval) clearInterval(entry.countdownInterval);
|
||
if (entry.root && entry.root.remove) entry.root.remove();
|
||
toastEntries.delete(jobId);
|
||
}
|
||
|
||
function removeToast(jobId, immediate) {
|
||
const entry = toastEntries.get(jobId);
|
||
if (!entry) return;
|
||
if (entry.timer) clearTimeout(entry.timer);
|
||
if (entry.countdownInterval) clearInterval(entry.countdownInterval);
|
||
if (immediate || !entry.root || !entry.root.classList) {
|
||
finishToastRemoval(jobId, entry);
|
||
return;
|
||
}
|
||
if (entry.leaving) return;
|
||
entry.leaving = true;
|
||
if (typeof entry.root.animate === "function") {
|
||
try {
|
||
const animation = entry.root.animate([
|
||
{ opacity: 1, transform: "translateY(0) scale(1)", filter: "blur(0)" },
|
||
{ opacity: 0, transform: "translateY(-24px) scale(.92)", filter: "blur(8px)" }
|
||
], {
|
||
duration: TOAST_EXIT_MS,
|
||
easing: "cubic-bezier(.55,0,1,.45)",
|
||
fill: "forwards"
|
||
});
|
||
if (animation && animation.finished && typeof animation.finished.then === "function") {
|
||
animation.finished.then(
|
||
() => finishToastRemoval(jobId, entry),
|
||
() => finishToastRemoval(jobId, entry)
|
||
);
|
||
}
|
||
} catch {
|
||
entry.root.classList.add("is-leaving");
|
||
}
|
||
} else {
|
||
entry.root.classList.add("is-leaving");
|
||
}
|
||
entry.removeTimer = setTimeout(() => finishToastRemoval(jobId, entry), TOAST_EXIT_MS + 80);
|
||
}
|
||
|
||
function transferToast(fromJobId, toJobId) {
|
||
if (!fromJobId || !toJobId || fromJobId === toJobId) return toastEntries.get(fromJobId) || null;
|
||
const entry = toastEntries.get(fromJobId);
|
||
if (!entry) return null;
|
||
const existing = toastEntries.get(toJobId);
|
||
if (existing && existing !== entry) finishToastRemoval(toJobId, existing);
|
||
toastEntries.delete(fromJobId);
|
||
toastEntries.set(toJobId, entry);
|
||
return entry;
|
||
}
|
||
|
||
function adoptPendingJob(job) {
|
||
const requestId = String(job && job.clientRequestId || "");
|
||
if (!requestId || !job.id) return;
|
||
const button = pendingJobButtons.get(requestId);
|
||
if (!button) return;
|
||
transferToast(requestId, job.id);
|
||
button.__localRgJobId = job.id;
|
||
jobButtons.set(job.id, button);
|
||
pendingJobButtons.delete(requestId);
|
||
}
|
||
|
||
function resetButtonForJob(jobId, button) {
|
||
if (!button || button.__localRgJobId !== jobId) return;
|
||
button.disabled = false;
|
||
button.title = "Скачать видео в лучшем доступном качестве";
|
||
button.__localRgJobId = null;
|
||
button.__localRgTerminalJobId = null;
|
||
button.__localRgResetTimer = null;
|
||
jobButtons.delete(jobId);
|
||
setButtonReadyState(button);
|
||
}
|
||
|
||
function syncButtonWithJob(job) {
|
||
const button = jobButtons.get(job.id);
|
||
if (!button || button.__localRgJobId !== job.id) return;
|
||
if (button.__localRgTerminalJobId === job.id) return;
|
||
if (job.state === "complete") {
|
||
button.disabled = true;
|
||
button.__localRgTerminalJobId = job.id;
|
||
button.title = "Скачивание завершено";
|
||
setButtonState(button, "done", "✓");
|
||
if (!button.__localRgResetTimer) {
|
||
button.__localRgResetTimer = setTimeout(
|
||
() => resetButtonForJob(job.id, button),
|
||
TOAST_COMPLETE_MS + TOAST_EXIT_MS
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (job.state === "error") {
|
||
button.disabled = true;
|
||
button.__localRgTerminalJobId = job.id;
|
||
button.title = job.error || job.status || "Ошибка загрузки";
|
||
setButtonState(button, "error", "!");
|
||
if (!button.__localRgResetTimer) {
|
||
button.__localRgResetTimer = setTimeout(() => resetButtonForJob(job.id, button), 7500);
|
||
}
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
button.title = job.status || "Скачивание…";
|
||
setButtonState(button, "working", "⇩");
|
||
}
|
||
|
||
function showDownloadToast(job) {
|
||
if (!job || !job.id) return;
|
||
adoptPendingJob(job);
|
||
if (job.state === "complete" && job.mediaId) {
|
||
markMediaDownloaded(job.mediaId, {
|
||
downloadedAt: Date.now(),
|
||
filename: job.filename || job.title || null,
|
||
profileSlug: job.profileSlug || null
|
||
});
|
||
}
|
||
syncButtonWithJob(job);
|
||
const stack = ensureToastStack();
|
||
let entry = toastEntries.get(job.id);
|
||
if (!entry) {
|
||
const root = document.createElement("div");
|
||
root.className = "local-rg-toast";
|
||
const main = document.createElement("div");
|
||
main.className = "local-rg-toast-main";
|
||
const icon = document.createElement("span");
|
||
icon.className = "local-rg-toast-icon";
|
||
const copy = document.createElement("span");
|
||
copy.className = "local-rg-toast-copy";
|
||
const title = document.createElement("strong");
|
||
title.className = "local-rg-toast-title";
|
||
const status = document.createElement("span");
|
||
status.className = "local-rg-toast-status";
|
||
const value = document.createElement("span");
|
||
value.className = "local-rg-toast-value";
|
||
const track = document.createElement("div");
|
||
track.className = "local-rg-toast-track";
|
||
const bar = document.createElement("div");
|
||
bar.className = "local-rg-toast-bar";
|
||
copy.appendChild(title);
|
||
copy.appendChild(status);
|
||
main.appendChild(icon);
|
||
main.appendChild(copy);
|
||
main.appendChild(value);
|
||
track.appendChild(bar);
|
||
root.appendChild(main);
|
||
root.appendChild(track);
|
||
stack.appendChild(root);
|
||
animateToastEntrance(root);
|
||
entry = {
|
||
root,
|
||
icon,
|
||
title,
|
||
status,
|
||
value,
|
||
bar,
|
||
timer: null,
|
||
removeTimer: null,
|
||
countdownInterval: null,
|
||
completionStartedAt: 0,
|
||
terminalState: null,
|
||
leaving: false
|
||
};
|
||
toastEntries.set(job.id, entry);
|
||
}
|
||
|
||
if (entry.terminalState || entry.leaving) return;
|
||
|
||
const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress) || 0)));
|
||
entry.root.dataset.state = job.state || "running";
|
||
entry.title.textContent = job.title || job.filename || "RedGifs video";
|
||
entry.status.textContent = job.error || job.status || "Подготовка загрузки…";
|
||
entry.value.textContent = progress + "%";
|
||
entry.bar.style.setProperty("width", progress + "%", "important");
|
||
entry.icon.textContent = job.state === "complete" ? "✓" : job.state === "error" ? "!" : "⇩";
|
||
|
||
if (job.state === "complete") {
|
||
entry.terminalState = "complete";
|
||
entry.completionStartedAt = Date.now();
|
||
const completeStatus = job.status || "Скачивание завершено";
|
||
const updateCountdown = () => {
|
||
const remaining = Math.max(0, TOAST_COMPLETE_MS - (Date.now() - entry.completionStartedAt));
|
||
const seconds = Math.ceil(remaining / 1000);
|
||
entry.status.textContent = remaining > 0
|
||
? completeStatus + " · закрытие через " + seconds + " с"
|
||
: completeStatus + " · закрытие…";
|
||
entry.value.textContent = seconds + " с";
|
||
entry.bar.style.setProperty("width", (remaining / TOAST_COMPLETE_MS * 100) + "%", "important");
|
||
};
|
||
updateCountdown();
|
||
entry.countdownInterval = setInterval(updateCountdown, 250);
|
||
entry.timer = setTimeout(() => removeToast(job.id), TOAST_COMPLETE_MS);
|
||
} else if (job.state === "error") {
|
||
entry.terminalState = "error";
|
||
entry.timer = setTimeout(() => removeToast(job.id), 7500);
|
||
}
|
||
}
|
||
|
||
function ensureDownloadButton(root, item, anchor) {
|
||
if (!root || !root.appendChild || !item) return;
|
||
ensureButtonStyles();
|
||
root.classList.add("local-rg-download-host");
|
||
let button = root.__localRgDownloadButton
|
||
|| Array.from(root.children || []).find((child) => child.classList && child.classList.contains("local-rg-download-button"));
|
||
if (!button) {
|
||
const reusable = findReusableExternalButton(root, item, anchor);
|
||
if (reusable && !reusable.adopt) return;
|
||
button = reusable && reusable.button;
|
||
}
|
||
if (button) {
|
||
button.__localRedgifsItem = item;
|
||
configureButtonLayout(root, button, anchor);
|
||
registerMediaButton(button, item);
|
||
return;
|
||
}
|
||
|
||
button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "local-rg-download-button";
|
||
button.dataset.runtimeVersion = CONTENT_SCRIPT_VERSION;
|
||
root.__localRgDownloadButton = button;
|
||
button.__localRedgifsItem = item;
|
||
button.title = "Скачать видео в лучшем доступном качестве";
|
||
button.setAttribute("aria-label", "Скачать видео");
|
||
setButtonState(button, "idle", "⇩");
|
||
button.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.stopImmediatePropagation();
|
||
if (button.disabled) return;
|
||
button.disabled = true;
|
||
const pendingId = "pending:"
|
||
+ (button.__localRedgifsItem.id || "video")
|
||
+ ":" + Date.now()
|
||
+ ":" + Math.random().toString(36).slice(2, 8);
|
||
pendingJobButtons.set(pendingId, button);
|
||
setButtonState(button, "working", "⇩");
|
||
showDownloadToast({
|
||
id: pendingId,
|
||
state: "running",
|
||
progress: 0,
|
||
title: button.__localRedgifsItem.title || "RedGifs video",
|
||
status: "Поиск лучшего качества…"
|
||
});
|
||
try {
|
||
button.title = "Скачать видео в лучшем доступном качестве";
|
||
const response = await chrome.runtime.sendMessage({
|
||
action: "downloadCapturedItem",
|
||
item: Object.assign({}, button.__localRedgifsItem, { clientRequestId: pendingId })
|
||
});
|
||
if (!response || !response.ok) {
|
||
throw new Error(response && response.error ? response.error : "Загрузка не запущена");
|
||
}
|
||
const jobId = response.result && response.result.jobId;
|
||
if (!jobId) throw new Error("Загрузка не вернула идентификатор задания");
|
||
pendingJobButtons.delete(pendingId);
|
||
transferToast(pendingId, jobId);
|
||
button.__localRgJobId = jobId;
|
||
jobButtons.set(jobId, button);
|
||
showDownloadToast({
|
||
id: jobId,
|
||
state: "queued",
|
||
progress: 0,
|
||
title: button.__localRedgifsItem.title || "RedGifs video",
|
||
status: "Загрузка запущена…"
|
||
});
|
||
try {
|
||
const latest = await chrome.runtime.sendMessage({ action: "getJob", jobId });
|
||
if (latest && latest.ok && latest.job) showDownloadToast(latest.job);
|
||
} catch {
|
||
// Subsequent progress messages keep the button and toast synchronized.
|
||
}
|
||
} catch (error) {
|
||
recordContentDiagnostic("error", "download-request-failed", {
|
||
mediaId: button.__localRedgifsItem && button.__localRedgifsItem.id,
|
||
name: error && error.name,
|
||
message: error && error.message
|
||
});
|
||
pendingJobButtons.delete(pendingId);
|
||
const errorId = "error:" + Date.now();
|
||
transferToast(pendingId, errorId);
|
||
showDownloadToast({
|
||
id: errorId,
|
||
state: "error",
|
||
progress: 0,
|
||
title: button.__localRedgifsItem.title || "RedGifs video",
|
||
status: error && error.message ? error.message : String(error),
|
||
error: error && error.message ? error.message : String(error)
|
||
});
|
||
button.title = error && error.message ? error.message : String(error);
|
||
setButtonState(button, "error", "!");
|
||
setTimeout(() => {
|
||
button.disabled = false;
|
||
setButtonReadyState(button);
|
||
}, 2600);
|
||
}
|
||
}, true);
|
||
configureButtonLayout(root, button, anchor);
|
||
registerMediaButton(button, item);
|
||
}
|
||
|
||
function extractCandidate(node) {
|
||
if (!node) return null;
|
||
const root = closestCard(node);
|
||
if (!root || isPageChromeRoot(root)) return null;
|
||
const feedNode = (root.matches && root.matches("[data-feed-item-id]"))
|
||
? root
|
||
: root.querySelector && root.querySelector("[data-feed-item-id]");
|
||
const feedId = feedNode && feedNode.getAttribute("data-feed-item-id");
|
||
const link = (root.matches && root.matches("a[href*='/watch/'], a[href*='/ifr/']"))
|
||
? root
|
||
: root.querySelector && root.querySelector("a[href*='/watch/'], a[href*='/ifr/']");
|
||
const href = normalizeUrl(link && (link.getAttribute("href") || link.href));
|
||
const video = (root.tagName === "VIDEO") ? root : root.querySelector && root.querySelector("video");
|
||
const previewImage = mediaPosterImage(root);
|
||
const directVideoUrl = videoSource(video);
|
||
const thumbnail = posterFromRoot(root, video);
|
||
const posterId = idFromPosterUrl(thumbnail);
|
||
if (!feedId && !href && posterId && !isExplicitMediaCard(root)) return null;
|
||
const stableId = feedId || idFromUrl(href) || posterId;
|
||
if (isGeneralFeedPage() && !stableId) return null;
|
||
const id = stableId || idFromUrl(directVideoUrl);
|
||
if (!id && !directVideoUrl) return null;
|
||
const item = {
|
||
id: id || null,
|
||
watchUrl: href || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null),
|
||
directVideoUrl: directVideoUrl || null,
|
||
thumbnail,
|
||
title: titleFromRoot(root, id),
|
||
profileSlug: profileSlugFromRoot(root) || currentProfileSlug()
|
||
};
|
||
return {
|
||
root,
|
||
item,
|
||
mediaAnchor: video || previewImage,
|
||
key: id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl
|
||
};
|
||
}
|
||
|
||
function isNearViewport(element, margin) {
|
||
if (!element || typeof element.getBoundingClientRect !== "function") return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const padding = Math.max(0, Number(margin) || 0);
|
||
const viewportWidth = Number(globalThis.innerWidth) || 0;
|
||
const viewportHeight = Number(globalThis.innerHeight) || 0;
|
||
return rect.width > 0
|
||
&& rect.height > 0
|
||
&& rect.bottom >= -padding
|
||
&& rect.top <= viewportHeight + padding
|
||
&& rect.right >= -padding
|
||
&& rect.left <= viewportWidth + padding;
|
||
}
|
||
|
||
function lightweightElement(element) {
|
||
if (!element) return null;
|
||
return {
|
||
tag: String(element.tagName || "").toLowerCase() || null,
|
||
id: element.id || null,
|
||
className: typeof element.className === "string" ? element.className.slice(0, 240) : null,
|
||
connected: !("isConnected" in element) || Boolean(element.isConnected)
|
||
};
|
||
}
|
||
|
||
function decorateCandidate(node) {
|
||
const extracted = extractCandidate(node);
|
||
if (!extracted || !extracted.mediaAnchor) return false;
|
||
ensureDownloadButton(extracted.root, extracted.item, extracted.mediaAnchor);
|
||
return true;
|
||
}
|
||
|
||
function scanPage() {
|
||
const items = [];
|
||
const seenItems = new Set();
|
||
const seenRoots = new Set();
|
||
lastScanDiagnostics = [];
|
||
|
||
const nodes = Array.from(document.querySelectorAll(MEDIA_CANDIDATE_SELECTOR));
|
||
for (const node of nodes) {
|
||
const root = closestCard(node);
|
||
if (!root || seenRoots.has(root)) continue;
|
||
seenRoots.add(root);
|
||
const extracted = extractCandidate(root);
|
||
if (!extracted) continue;
|
||
const duplicate = seenItems.has(extracted.key);
|
||
if (!duplicate) seenItems.add(extracted.key);
|
||
if (extracted.mediaAnchor && isNearViewport(extracted.mediaAnchor, 900)) {
|
||
ensureDownloadButton(extracted.root, extracted.item, extracted.mediaAnchor);
|
||
}
|
||
if (diagnosticsEnabled && lastScanDiagnostics.length < 80) {
|
||
lastScanDiagnostics.push({
|
||
key: extracted.key,
|
||
duplicate,
|
||
outcome: extracted.mediaAnchor ? "candidate" : "no-video-or-redgifs-poster",
|
||
item: {
|
||
id: extracted.item.id,
|
||
title: extracted.item.title,
|
||
watchUrl: extracted.item.watchUrl,
|
||
thumbnail: extracted.item.thumbnail
|
||
},
|
||
root: lightweightElement(extracted.root),
|
||
anchor: lightweightElement(extracted.mediaAnchor)
|
||
});
|
||
}
|
||
if (!duplicate && items.length < 240) items.push(extracted.item);
|
||
}
|
||
pruneDetachedButtons();
|
||
scheduleExternalLayout();
|
||
|
||
return {
|
||
pageUrl: location.href,
|
||
pageTitle: document.title || "RedGifs",
|
||
items
|
||
};
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
if (!message) return false;
|
||
if (message.action === "getContentRuntimeInfo") {
|
||
sendResponse({
|
||
ok: true,
|
||
version: CONTENT_SCRIPT_VERSION,
|
||
diagnosticsEnabled,
|
||
buttons: document.querySelectorAll(".local-rg-download-button").length
|
||
});
|
||
return false;
|
||
}
|
||
if (message.action === "downloadProgress") {
|
||
showDownloadToast(message.job);
|
||
try {
|
||
sendResponse({ ok: true });
|
||
} catch {
|
||
// No response channel is required.
|
||
}
|
||
return false;
|
||
}
|
||
if (message.action === "captureExtensionDiagnostics" || message.action === "captureButtonDiagnostics") {
|
||
if (!diagnosticsEnabled) {
|
||
sendResponse({ ok: false, error: "Включите диагностику в настройках расширения." });
|
||
return false;
|
||
}
|
||
try {
|
||
sendResponse({ ok: true, report: capturePageDiagnostics() });
|
||
} catch (error) {
|
||
recordContentDiagnostic("error", "diagnostic-capture-failed", {
|
||
name: error && error.name,
|
||
message: error && error.message
|
||
});
|
||
sendResponse({ ok: false, error: error && error.message ? error.message : String(error) });
|
||
}
|
||
return false;
|
||
}
|
||
if (message.action !== "scanRedgifsPage") return false;
|
||
try {
|
||
sendResponse({ ok: true, result: scanPage() });
|
||
} catch (error) {
|
||
recordContentDiagnostic("error", "manual-scan-failed", {
|
||
name: error && error.name,
|
||
message: error && error.message
|
||
});
|
||
sendResponse({ ok: false, error: error && error.message ? error.message : String(error) });
|
||
}
|
||
return false;
|
||
});
|
||
|
||
const observedCandidates = new Set();
|
||
const intersectingCandidates = new WeakSet();
|
||
const pendingDecorationRoots = new Set();
|
||
let decorationTask = null;
|
||
let documentSweepTimer = null;
|
||
let documentSweepTask = null;
|
||
let tagTransitionUntil = 0;
|
||
|
||
const TAG_TRANSITION_GUARD_MS = 12000;
|
||
const MUTATION_BATCH_LIMIT = 64;
|
||
const MUTATION_NODE_LIMIT = 96;
|
||
|
||
function isExtensionOwnedNode(node) {
|
||
const element = node && node.nodeType === Node.ELEMENT_NODE
|
||
? node
|
||
: node && node.parentElement;
|
||
if (!element || typeof element.closest !== "function") return false;
|
||
return Boolean(element.closest([
|
||
"#local-redgifs-download-styles",
|
||
"#local-rg-toast-stack",
|
||
".local-rg-download-button"
|
||
].join(", ")));
|
||
}
|
||
|
||
function requestDecorationFlush() {
|
||
if (decorationTask != null) return;
|
||
const run = (deadline) => {
|
||
decorationTask = null;
|
||
const startedAt = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||
let processed = 0;
|
||
while (pendingDecorationRoots.size && processed < 16) {
|
||
if (processed > 0 && deadline && typeof deadline.timeRemaining === "function" && deadline.timeRemaining() < 2) break;
|
||
const root = pendingDecorationRoots.values().next().value;
|
||
pendingDecorationRoots.delete(root);
|
||
if (root && (!("isConnected" in root) || root.isConnected)) {
|
||
try {
|
||
decorateCandidate(root);
|
||
} catch (error) {
|
||
recordContentDiagnostic("error", "automatic-decoration-failed", {
|
||
name: error && error.name,
|
||
message: error && error.message
|
||
});
|
||
}
|
||
}
|
||
processed += 1;
|
||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||
if (now - startedAt >= 9) break;
|
||
}
|
||
pruneDetachedButtons();
|
||
if (pendingDecorationRoots.size) requestDecorationFlush();
|
||
};
|
||
if (typeof globalThis.requestIdleCallback === "function") {
|
||
decorationTask = globalThis.requestIdleCallback(run, { timeout: 240 });
|
||
} else {
|
||
decorationTask = setTimeout(() => run(null), 48);
|
||
}
|
||
}
|
||
|
||
function queueCandidateDecoration(candidate) {
|
||
if (!candidate || isExtensionOwnedNode(candidate)) return;
|
||
const root = closestCard(candidate);
|
||
if (!root || isPageChromeRoot(root)) return;
|
||
pendingDecorationRoots.add(root);
|
||
requestDecorationFlush();
|
||
}
|
||
|
||
const candidateObserver = typeof globalThis.IntersectionObserver === "function"
|
||
? new globalThis.IntersectionObserver((entries) => {
|
||
for (const entry of entries) {
|
||
if (entry.isIntersecting) {
|
||
intersectingCandidates.add(entry.target);
|
||
queueCandidateDecoration(entry.target);
|
||
} else {
|
||
intersectingCandidates.delete(entry.target);
|
||
}
|
||
}
|
||
}, { root: null, rootMargin: "800px 240px", threshold: 0 })
|
||
: null;
|
||
|
||
function observeCandidate(candidate) {
|
||
if (!candidate || candidate.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(candidate)) return;
|
||
if (typeof candidate.matches !== "function" || !candidate.matches(MEDIA_CANDIDATE_SELECTOR)) return;
|
||
if (observedCandidates.has(candidate)) return;
|
||
observedCandidates.add(candidate);
|
||
if (candidateObserver) {
|
||
candidateObserver.observe(candidate);
|
||
} else {
|
||
queueCandidateDecoration(candidate);
|
||
}
|
||
}
|
||
|
||
function observeCandidateScope(scope) {
|
||
if (!scope || isExtensionOwnedNode(scope)) return false;
|
||
let found = false;
|
||
if (scope.nodeType === Node.ELEMENT_NODE && typeof scope.matches === "function" && scope.matches(MEDIA_CANDIDATE_SELECTOR)) {
|
||
observeCandidate(scope);
|
||
found = true;
|
||
}
|
||
if (typeof scope.querySelectorAll !== "function") return found;
|
||
for (const candidate of scope.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)) {
|
||
observeCandidate(candidate);
|
||
found = true;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
function unobserveCandidateScope(scope) {
|
||
if (!candidateObserver || !scope || scope.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(scope)) return false;
|
||
let found = false;
|
||
if (typeof scope.matches === "function" && scope.matches(MEDIA_CANDIDATE_SELECTOR)) {
|
||
candidateObserver.unobserve(scope);
|
||
observedCandidates.delete(scope);
|
||
intersectingCandidates.delete(scope);
|
||
found = true;
|
||
}
|
||
if (typeof scope.querySelectorAll !== "function") return found;
|
||
for (const candidate of scope.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)) {
|
||
candidateObserver.unobserve(candidate);
|
||
observedCandidates.delete(candidate);
|
||
intersectingCandidates.delete(candidate);
|
||
found = true;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
function pruneObservedCandidates() {
|
||
for (const candidate of Array.from(observedCandidates)) {
|
||
const connected = !("isConnected" in candidate) || candidate.isConnected;
|
||
const stillMatches = connected
|
||
&& typeof candidate.matches === "function"
|
||
&& candidate.matches(MEDIA_CANDIDATE_SELECTOR);
|
||
if (stillMatches) continue;
|
||
if (candidateObserver) candidateObserver.unobserve(candidate);
|
||
observedCandidates.delete(candidate);
|
||
intersectingCandidates.delete(candidate);
|
||
}
|
||
}
|
||
|
||
function rebuildCandidateRegistry() {
|
||
pruneObservedCandidates();
|
||
for (const candidate of document.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)) {
|
||
observeCandidate(candidate);
|
||
if (isNearViewport(candidate, 900)) queueCandidateDecoration(candidate);
|
||
}
|
||
pruneDetachedButtons();
|
||
scheduleExternalLayout();
|
||
}
|
||
|
||
function cancelDocumentSweepTask() {
|
||
if (documentSweepTimer != null) {
|
||
clearTimeout(documentSweepTimer);
|
||
documentSweepTimer = null;
|
||
}
|
||
if (documentSweepTask == null) return;
|
||
if (typeof globalThis.cancelIdleCallback === "function") {
|
||
globalThis.cancelIdleCallback(documentSweepTask);
|
||
} else {
|
||
clearTimeout(documentSweepTask);
|
||
}
|
||
documentSweepTask = null;
|
||
}
|
||
|
||
function requestDocumentSweep(delay) {
|
||
cancelDocumentSweepTask();
|
||
documentSweepTimer = setTimeout(() => {
|
||
documentSweepTimer = null;
|
||
const run = () => {
|
||
documentSweepTask = null;
|
||
try {
|
||
rebuildCandidateRegistry();
|
||
} catch (error) {
|
||
recordContentDiagnostic("error", "candidate-registry-rebuild-failed", {
|
||
name: error && error.name,
|
||
message: error && error.message
|
||
});
|
||
}
|
||
};
|
||
if (typeof globalThis.requestIdleCallback === "function") {
|
||
documentSweepTask = globalThis.requestIdleCallback(run, { timeout: 700 });
|
||
} else {
|
||
documentSweepTask = setTimeout(run, 0);
|
||
}
|
||
}, Math.max(0, Number(delay) || 0));
|
||
}
|
||
|
||
function isTagNavigationControl(target) {
|
||
if (!target || typeof target.closest !== "function") return false;
|
||
return Boolean(target.closest([
|
||
".tagButton",
|
||
"button[aria-label$=' tag']",
|
||
"[role='button'][aria-label$=' tag']"
|
||
].join(", ")));
|
||
}
|
||
|
||
function armTagTransitionGuard(event) {
|
||
if (!event || !isTagNavigationControl(event.target)) return;
|
||
tagTransitionUntil = Date.now() + TAG_TRANSITION_GUARD_MS;
|
||
pendingDecorationRoots.clear();
|
||
requestDocumentSweep(260);
|
||
recordContentDiagnostic("info", "tag-transition-guard-armed", {
|
||
label: event.target && event.target.closest
|
||
? (event.target.closest("[aria-label]") || {}).ariaLabel || null
|
||
: null,
|
||
guardMs: TAG_TRANSITION_GUARD_MS
|
||
});
|
||
}
|
||
|
||
function isLargeMutationBatch(mutations) {
|
||
if (!mutations || mutations.length >= MUTATION_BATCH_LIMIT) return true;
|
||
let changedNodes = 0;
|
||
for (const mutation of mutations) {
|
||
changedNodes += (mutation.addedNodes && mutation.addedNodes.length) || 0;
|
||
changedNodes += (mutation.removedNodes && mutation.removedNodes.length) || 0;
|
||
if (changedNodes >= MUTATION_NODE_LIMIT) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function refreshKnownButtonContexts() {
|
||
const buttons = new Set(externalButtons);
|
||
for (const buttonsForMedia of mediaButtons.values()) {
|
||
for (const button of buttonsForMedia) buttons.add(button);
|
||
}
|
||
for (const button of buttons) {
|
||
const root = button && button.__localRgRoot;
|
||
const anchor = button && button.__localRgAnchor;
|
||
if (!root || ("isConnected" in root && !root.isConnected)) continue;
|
||
configureButtonLayout(root, button, anchor || root);
|
||
}
|
||
pruneDetachedButtons();
|
||
scheduleExternalLayout();
|
||
}
|
||
|
||
function handlePageMutations(mutations) {
|
||
if (Date.now() < tagTransitionUntil || isLargeMutationBatch(mutations)) {
|
||
requestDocumentSweep(260);
|
||
return;
|
||
}
|
||
let pageChanged = false;
|
||
for (const mutation of mutations) {
|
||
if (!mutation || isExtensionOwnedNode(mutation.target)) continue;
|
||
if (mutation.type === "attributes") {
|
||
const matchesCandidate = mutation.target.matches && mutation.target.matches(MEDIA_CANDIDATE_SELECTOR);
|
||
if (!matchesCandidate) {
|
||
if (candidateObserver && observedCandidates.has(mutation.target)) {
|
||
candidateObserver.unobserve(mutation.target);
|
||
observedCandidates.delete(mutation.target);
|
||
intersectingCandidates.delete(mutation.target);
|
||
pageChanged = true;
|
||
}
|
||
continue;
|
||
}
|
||
observeCandidate(mutation.target);
|
||
if (intersectingCandidates.has(mutation.target)) queueCandidateDecoration(mutation.target);
|
||
pageChanged = true;
|
||
continue;
|
||
}
|
||
for (const addedNode of mutation.addedNodes || []) {
|
||
if (!addedNode || addedNode.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(addedNode)) continue;
|
||
if (observeCandidateScope(addedNode)) pageChanged = true;
|
||
}
|
||
for (const removedNode of mutation.removedNodes || []) {
|
||
if (!removedNode || removedNode.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(removedNode)) continue;
|
||
if (unobserveCandidateScope(removedNode)) pageChanged = true;
|
||
}
|
||
}
|
||
if (pageChanged) {
|
||
pruneDetachedButtons();
|
||
scheduleExternalLayout();
|
||
}
|
||
}
|
||
|
||
const observer = new MutationObserver(handlePageMutations);
|
||
observer.observe(document.documentElement, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ["src", "poster", "data-src", "data-lazy-src", "data-feed-item-id", "href"]
|
||
});
|
||
if (typeof globalThis.addEventListener === "function") {
|
||
globalThis.addEventListener("click", armTagTransitionGuard, { capture: true, passive: true });
|
||
globalThis.addEventListener("scroll", scheduleFeedRefresh, { passive: true, capture: true });
|
||
globalThis.addEventListener("resize", scheduleExternalLayout, { passive: true });
|
||
globalThis.addEventListener("fullscreenchange", () => {
|
||
refreshKnownButtonContexts();
|
||
observeCandidateScope(document.fullscreenElement || document);
|
||
setTimeout(refreshKnownButtonContexts, 260);
|
||
});
|
||
}
|
||
try {
|
||
chrome.storage.local.get("diagnosticsEnabled").then((stored) => {
|
||
applyDiagnosticsState(Boolean(stored && stored.diagnosticsEnabled));
|
||
}).catch(() => {});
|
||
chrome.storage.onChanged.addListener((changes, areaName) => {
|
||
if (areaName === "local" && changes.diagnosticsEnabled) {
|
||
applyDiagnosticsState(Boolean(changes.diagnosticsEnabled.newValue));
|
||
}
|
||
});
|
||
} catch {
|
||
applyDiagnosticsState(false);
|
||
}
|
||
observeCandidateScope(document);
|
||
}
|