"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 = [ '' ].join(""); const BUTTON_ICON_CHECK = [ '' ].join(""); const BUTTON_ICON_REDOWNLOAD = [ '' ].join(""); const BUTTON_ICON_ERROR = [ '' ].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); }