"use strict"; if (!globalThis.__localRedgifsScannerInstalled) { globalThis.__localRedgifsScannerInstalled = true; 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 ANCHOR_RECONNECT_GRACE_MS = 500; let historyCheckTimer = null; let externalLayoutFrame = null; let externalReconnectTimer = null; let lastScanDiagnostics = []; const contentDiagnosticEvents = []; function recordContentDiagnostic(level, event, details) { 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; 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, buttonRect: diagnosticRect(button), anchorRect: diagnosticRect(anchor), rootRect: diagnosticRect(root), 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 > 40) button.__localRgDecisionHistory.splice(0, 10); } 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; } setStyleProperty(button, "display", "none"); setButtonLayoutReason(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 { 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(); scheduleDecoration(); } 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) }, 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 buttons) { if (!button) continue; const previousRoot = button.__localRgRoot; const previousAnchor = button.__localRgAnchor || previousRoot; const previousOverlay = button.__localRgOverlay || modalContainerFor(previousAnchor) || modalContainerFor(previousRoot); const nextOverlay = modalContainerFor(anchor || root) || modalContainerFor(root); const sameRoot = previousRoot === root; const previousConnected = previousRoot && (!("isConnected" in previousRoot) || previousRoot.isConnected); const previousVisible = Math.max( visibleAreaFor(previousRoot), visibleAreaFor(previousAnchor) ) > 0; const nextVisible = Math.max( visibleAreaFor(root), visibleAreaFor(anchor || root) ) > 0; const adopt = sameRoot || (!previousOverlay && Boolean(nextOverlay)) || (!previousConnected && nextVisible) || (!previousVisible && nextVisible); if (!adopt) return { button, adopt: false }; 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 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"; 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 scanPage() { const items = []; const seen = new Set(); lastScanDiagnostics = []; function add(node) { if (!node) return; const root = closestCard(node); if (!root || isPageChromeRoot(root)) return; 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; const stableId = feedId || idFromUrl(href) || posterId; if (isGeneralFeedPage() && !stableId) return; const id = stableId || idFromUrl(directVideoUrl); if (!id && !directVideoUrl) return; const key = id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl; const duplicate = seen.has(key); if (!duplicate) seen.add(key); 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() }; const mediaAnchor = video || previewImage; if (mediaAnchor) ensureDownloadButton(root, item, mediaAnchor); lastScanDiagnostics.push({ key, duplicate, outcome: mediaAnchor ? "decorated" : "no-video-or-redgifs-poster", item: { id: item.id, title: item.title, watchUrl: item.watchUrl, thumbnail: item.thumbnail }, root: diagnosticElement(root), anchor: diagnosticElement(mediaAnchor) }); if (!duplicate && items.length < 240) items.push(item); } const nodes = Array.from(document.querySelectorAll( "[data-feed-item-id], a[href*='/watch/'], a[href*='/ifr/'], video, img[src], img[data-src], img[data-lazy-src]" )); nodes.sort((left, right) => Number(Boolean(modalContainerFor(right))) - Number(Boolean(modalContainerFor(left)))); for (const node of nodes) add(node); scheduleExternalLayout(); return { pageUrl: location.href, pageTitle: document.title || "RedGifs", items }; } chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message) 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") { 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; }); let decorateTimer = null; function scheduleDecoration() { if (decorateTimer != null) return; let ranSynchronously = false; const timer = setTimeout(() => { ranSynchronously = true; decorateTimer = null; try { scanPage(); } catch (error) { recordContentDiagnostic("error", "automatic-scan-failed", { name: error && error.name, message: error && error.message }); // A later DOM mutation will retry. } }, 160); if (!ranSynchronously) decorateTimer = timer; } const observer = new MutationObserver(scheduleDecoration); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "poster", "data-src", "data-lazy-src", "data-feed-item-id"] }); if (typeof globalThis.addEventListener === "function") { globalThis.addEventListener("scroll", scheduleFeedRefresh, { passive: true, capture: true }); globalThis.addEventListener("resize", scheduleExternalLayout, { passive: true }); } scheduleDecoration(); }