"use strict"; const urlInput = document.getElementById("urlInput"); const resolveButton = document.getElementById("resolveButton"); const settingsButton = document.getElementById("settingsButton"); const settingsPanel = document.getElementById("settingsPanel"); const baseFolderInput = document.getElementById("baseFolder"); const groupByProfileInput = document.getElementById("groupByProfile"); const saveAsInput = document.getElementById("saveAs"); const diagnosticsEnabledInput = document.getElementById("diagnosticsEnabled"); const settingsPathPreview = document.getElementById("settingsPathPreview"); const settingsSaveButton = document.getElementById("settingsSaveButton"); const settingsResetButton = document.getElementById("settingsResetButton"); const settingsStatus = document.getElementById("settingsStatus"); const alternativeToggle = document.getElementById("alternativeToggle"); const alternativePanel = document.getElementById("alternativePanel"); const diagnosticButton = document.getElementById("diagnosticButton"); const diagnosticStatus = document.getElementById("diagnosticStatus"); const saveDestination = document.getElementById("saveDestination"); const statusBox = document.getElementById("status"); const collectionBox = document.getElementById("collection"); const collectionTitle = document.getElementById("collectionTitle"); const collectionCount = document.getElementById("collectionCount"); const collectionItems = document.getElementById("collectionItems"); const resultBox = document.getElementById("result"); const thumbnail = document.getElementById("thumbnail"); const mediaTitle = document.getElementById("mediaTitle"); const formatsBox = document.getElementById("formats"); const progressCard = document.getElementById("progressCard"); const progressLabel = document.getElementById("progressLabel"); const progressValue = document.getElementById("progressValue"); const progressBar = document.getElementById("progressBar"); let currentMedia = null; let pollingTimer = null; let activeTabId = null; let selectedProfileSlug = null; let activeTabUrl = ""; let alternativeScanned = false; const DEFAULT_DOWNLOAD_SETTINGS = Object.freeze({ baseFolder: "RedGifs", groupByProfile: true, saveAs: false }); let downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS); let diagnosticsEnabled = false; let settingsStatusTimer = null; let popupBlurTimer = null; const panelAnimations = new WeakMap(); function panelIsExpanded(element) { if (element.dataset.popupExpanded === "true") return true; if (element.dataset.popupExpanded === "false") return false; return !element.classList.contains("hidden"); } function clearPanelMotionStyles(element) { element.style.removeProperty("overflow"); element.style.removeProperty("will-change"); element.style.removeProperty("transform-origin"); } function setPanelExpanded(element, expanded, options) { const config = Object.assign({ duration: 260, offset: 8, scale: 0.985 }, options || {}); const previous = panelAnimations.get(element); if (previous) previous.cancel(); clearPanelMotionStyles(element); element.dataset.popupExpanded = String(expanded); const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduceMotion || typeof element.animate !== "function") { element.classList.toggle("hidden", !expanded); panelAnimations.delete(element); return Promise.resolve(); } if (expanded) element.classList.remove("hidden"); if (!expanded && element.classList.contains("hidden")) return Promise.resolve(); const fullHeight = Math.max(1, Math.ceil(element.getBoundingClientRect().height)); const computedStyle = window.getComputedStyle(element); const expandedBox = { marginTop: computedStyle.marginTop, marginBottom: computedStyle.marginBottom, paddingTop: computedStyle.paddingTop, paddingBottom: computedStyle.paddingBottom, borderTopWidth: computedStyle.borderTopWidth, borderBottomWidth: computedStyle.borderBottomWidth }; element.style.overflow = "hidden"; element.style.willChange = "height, margin, padding, border-width, opacity, transform"; element.style.transformOrigin = "top center"; const collapsedFrame = { height: "0px", marginTop: "0px", marginBottom: "0px", paddingTop: "0px", paddingBottom: "0px", borderTopWidth: "0px", borderBottomWidth: "0px", opacity: 0, transform: `translateY(-${config.offset}px) scale(${config.scale})` }; const expandedFrame = { height: fullHeight + "px", ...expandedBox, opacity: 1, transform: "translateY(0) scale(1)" }; const animation = element.animate( expanded ? [collapsedFrame, expandedFrame] : [expandedFrame, collapsedFrame], { duration: config.duration, easing: expanded ? "cubic-bezier(.22, 1, .36, 1)" : "cubic-bezier(.4, 0, .2, 1)" } ); panelAnimations.set(element, animation); return animation.finished.catch(() => {}).then(() => { if (panelAnimations.get(element) !== animation) return; if (!expanded) element.classList.add("hidden"); clearPanelMotionStyles(element); panelAnimations.delete(element); }); } function reportPopupDiagnostic(level, event, details) { if (!diagnosticsEnabled) return; try { const pending = chrome.runtime.sendMessage({ action: "diagnosticEvent", level, area: "popup", event, details: details || {} }); if (pending && typeof pending.catch === "function") pending.catch(() => {}); } catch { // A failed logger must not replace the original UI error. } } function cleanFolderPath(value) { return String(value || "") .replace(/\\/g, "/") .split("/") .map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim()) .filter((part) => part && part !== "." && part !== "..") .slice(0, 8) .join("/"); } function profileFromUrl(value) { try { const match = new URL(value).pathname.match(/\/users\/([^/?#]+)/i); return match && match[1] ? decodeURIComponent(match[1]) : null; } catch { return null; } } function diagnosticPageUrl(value) { try { const parsed = new URL(value); parsed.username = ""; parsed.password = ""; parsed.search = ""; parsed.hash = ""; return parsed.toString(); } catch { return String(value || "").slice(0, 300); } } function renderSaveDestination() { saveDestination.textContent = ""; if (downloadSettings.saveAs) { saveDestination.textContent = "Для каждого видео Chrome спросит место сохранения."; return; } const parts = ["Загрузки"]; const baseFolder = cleanFolderPath(downloadSettings.baseFolder); if (baseFolder) parts.push(baseFolder); const profile = profileFromUrl(activeTabUrl); if (downloadSettings.groupByProfile && profile) parts.push(cleanFolderPath(profile)); saveDestination.appendChild(document.createTextNode("Видео сохраняются в: ")); const strong = document.createElement("strong"); strong.textContent = parts.join("/"); saveDestination.appendChild(strong); } function readSettingsForm() { return { baseFolder: cleanFolderPath(baseFolderInput.value), groupByProfile: groupByProfileInput.checked, saveAs: saveAsInput.checked }; } function renderSettingsPathPreview() { const settings = readSettingsForm(); if (settings.saveAs) { settingsPathPreview.textContent = "Chrome спросит место сохранения"; return; } const parts = ["Загрузки"]; if (settings.baseFolder) parts.push(settings.baseFolder); if (settings.groupByProfile) parts.push(profileFromUrl(activeTabUrl) || "profile"); parts.push("video.mp4"); settingsPathPreview.textContent = parts.join("/"); } function writeSettingsForm(settings) { baseFolderInput.value = settings.baseFolder || ""; groupByProfileInput.checked = settings.groupByProfile !== false; saveAsInput.checked = Boolean(settings.saveAs); diagnosticsEnabledInput.checked = diagnosticsEnabled; renderSettingsPathPreview(); } function renderDiagnosticControls() { diagnosticButton.disabled = !diagnosticsEnabled; diagnosticButton.title = diagnosticsEnabled ? "Скачать диагностический лог" : "Сначала включите диагностику в настройках"; if (!diagnosticsEnabled) { diagnosticStatus.className = "diagnostic-status"; diagnosticStatus.textContent = "Диагностика выключена."; } else if (diagnosticStatus.textContent === "Диагностика выключена.") { diagnosticStatus.textContent = ""; } } function showSettingsStatus(text, kind) { if (settingsStatusTimer) clearTimeout(settingsStatusTimer); settingsStatusTimer = null; settingsStatus.textContent = text || ""; settingsStatus.className = "settings-status" + (kind ? " " + kind : ""); if (text) { settingsStatusTimer = setTimeout(() => { settingsStatus.textContent = ""; settingsStatusTimer = null; }, 2600); } } async function saveDownloadSettings() { const settings = readSettingsForm(); const nextDiagnosticsEnabled = Boolean(diagnosticsEnabledInput.checked); settingsSaveButton.disabled = true; try { await chrome.storage.local.set({ downloadSettings: settings, diagnosticsEnabled: nextDiagnosticsEnabled }); downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, settings); diagnosticsEnabled = nextDiagnosticsEnabled; writeSettingsForm(downloadSettings); renderDiagnosticControls(); renderSaveDestination(); showSettingsStatus("Настройки сохранены."); } catch (error) { reportPopupDiagnostic("error", "settings-save-failed", { name: error && error.name, message: error && error.message }); showSettingsStatus(error && error.message ? error.message : "Не удалось сохранить настройки.", "error"); } finally { settingsSaveButton.disabled = false; } } function setStatus(text, kind) { statusBox.textContent = text; statusBox.className = "status" + (kind ? " " + kind : ""); } function setBusy(busy) { resolveButton.disabled = busy; resolveButton.textContent = busy ? "Сканирую…" : "Сканировать"; } function stopPolling() { if (pollingTimer) { clearTimeout(pollingTimer); pollingTimer = null; } } function renderMedia(media) { setPanelExpanded(collectionBox, false, { duration: 210, offset: 5 }); currentMedia = media; if (media.profileSlug) selectedProfileSlug = media.profileSlug; renderSaveDestination(); mediaTitle.textContent = media.title || media.id || "RedGifs video"; formatsBox.textContent = ""; if (media.thumbnail) { thumbnail.src = media.thumbnail; thumbnail.classList.remove("hidden"); resultBox.classList.remove("no-image"); } else { thumbnail.removeAttribute("src"); thumbnail.classList.add("hidden"); resultBox.classList.add("no-image"); } media.formats.forEach((format, index) => { const button = document.createElement("button"); button.type = "button"; button.className = "format-button"; button.textContent = "Скачать · " + format.label; button.addEventListener("click", () => downloadFormat(index, button)); formatsBox.appendChild(button); }); setPanelExpanded(resultBox, true, { duration: 240, offset: 6 }); } function renderCollection(collection) { currentMedia = null; setPanelExpanded(resultBox, false, { duration: 210, offset: 5 }); collectionItems.textContent = ""; collectionTitle.textContent = collection.title || "Видимые ролики"; collectionCount.textContent = collection.items.length + " шт."; collection.items.forEach((item) => { const button = document.createElement("button"); button.type = "button"; button.className = "collection-item"; if (item.thumbnail) { const image = document.createElement("img"); image.className = "collection-thumb"; image.src = item.thumbnail; image.alt = ""; button.appendChild(image); } else { button.classList.add("no-image"); } const copy = document.createElement("span"); copy.className = "collection-copy"; const title = document.createElement("strong"); title.textContent = item.title || item.id || "RedGifs video"; const id = document.createElement("small"); id.textContent = item.id ? "ID: " + item.id : "Прямой источник"; copy.appendChild(title); copy.appendChild(id); button.appendChild(copy); button.addEventListener("click", async () => { const target = item.watchUrl || item.directVideoUrl; if (!target) return; selectedProfileSlug = item.profileSlug || null; urlInput.value = target; await resolveCurrentUrl(); }); collectionItems.appendChild(button); }); setPanelExpanded(collectionBox, true, { duration: 240, offset: 6 }); } async function resolveCurrentUrl() { const value = urlInput.value.trim(); if (!value) { setStatus("Вставьте ссылку на страницу ролика.", "error"); return; } stopPolling(); setPanelExpanded(progressCard, false, { duration: 190, offset: 4 }); setPanelExpanded(resultBox, false, { duration: 210, offset: 5 }); setPanelExpanded(collectionBox, false, { duration: 210, offset: 5 }); currentMedia = null; setBusy(true); setStatus("Получаю временный токен и адреса видео…"); try { const response = await chrome.runtime.sendMessage({ action: "resolve", url: value, tabId: activeTabId }); if (!response || !response.ok) { throw new Error(response && response.error ? response.error : "Нет ответа от расширения."); } if (response.media.type === "collection") { renderCollection(response.media); setStatus("Найдены видимые ролики. Выберите нужный.", "success"); } else { renderMedia(response.media); setStatus("Найдено форматов: " + response.media.formats.length, "success"); } } catch (error) { reportPopupDiagnostic("error", "resolve-failed", { url: value, name: error && error.name, message: error && error.message }); setStatus(error && error.message ? error.message : String(error), "error"); } finally { setBusy(false); } } async function downloadFormat(index, button) { if (!currentMedia || !currentMedia.formats[index]) return; const format = currentMedia.formats[index]; button.disabled = true; setStatus(format.kind === "hls" ? "Запускаю локальную сборку HLS…" : "Запускаю локальную загрузку…"); try { const response = await chrome.runtime.sendMessage({ action: "download", format, title: currentMedia.title, id: currentMedia.id, sourceUrl: currentMedia.sourceUrl, profileSlug: selectedProfileSlug, tabId: activeTabId }); if (!response || !response.ok) { throw new Error(response && response.error ? response.error : "Загрузка не запущена."); } if (response.result.jobId) { setPanelExpanded(progressCard, true, { duration: 220, offset: 5 }); setStatus(response.result.mode === "hls" ? "HLS загружается и собирается локально." : "Видео загружается локально.", "success"); pollJob(response.result.jobId); } else { setStatus("Загрузка передана Chrome: " + response.result.filename, "success"); } } catch (error) { reportPopupDiagnostic("error", "download-start-failed", { mediaId: currentMedia && currentMedia.id, format: format && format.label, name: error && error.name, message: error && error.message }); setStatus(error && error.message ? error.message : String(error), "error"); } finally { button.disabled = false; } } async function pollJob(jobId) { stopPolling(); try { const response = await chrome.runtime.sendMessage({ action: "getJob", jobId }); if (!response || !response.ok || !response.job) { throw new Error("Состояние HLS-задачи потеряно."); } const job = response.job; const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress) || 0))); progressLabel.textContent = job.status || "Обработка HLS…"; progressValue.textContent = progress + "%"; progressBar.style.width = progress + "%"; if (job.state === "complete") { setStatus("Готово: " + (job.filename || "файл передан Chrome"), "success"); return; } if (job.state === "error") throw new Error(job.error || "Ошибка обработки HLS."); pollingTimer = setTimeout(() => pollJob(jobId), 500); } catch (error) { reportPopupDiagnostic("error", "job-poll-failed", { jobId, name: error && error.name, message: error && error.message }); setStatus(error && error.message ? error.message : String(error), "error"); } } resolveButton.addEventListener("click", () => { selectedProfileSlug = null; resolveCurrentUrl(); }); urlInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { selectedProfileSlug = null; resolveCurrentUrl(); } }); settingsButton.addEventListener("click", () => { const opening = !panelIsExpanded(settingsPanel); setPanelExpanded(settingsPanel, opening, { duration: 300, offset: 10, scale: 0.98 }); settingsButton.setAttribute("aria-expanded", String(opening)); settingsButton.setAttribute("aria-label", opening ? "Скрыть настройки" : "Открыть настройки"); settingsButton.title = opening ? "Скрыть настройки" : "Настройки"; if (opening) renderSettingsPathPreview(); }); window.addEventListener("focus", () => { if (popupBlurTimer) clearTimeout(popupBlurTimer); popupBlurTimer = null; }); window.addEventListener("blur", () => { if (popupBlurTimer) clearTimeout(popupBlurTimer); popupBlurTimer = setTimeout(() => { popupBlurTimer = null; if (!document.hasFocus()) window.close(); }, 140); }); requestAnimationFrame(() => { try { window.focus(); } catch { // Browser-native popup focus remains the fallback. } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") window.close(); }); baseFolderInput.addEventListener("input", renderSettingsPathPreview); groupByProfileInput.addEventListener("change", renderSettingsPathPreview); saveAsInput.addEventListener("change", renderSettingsPathPreview); settingsSaveButton.addEventListener("click", saveDownloadSettings); settingsResetButton.addEventListener("click", () => { diagnosticsEnabled = false; writeSettingsForm(DEFAULT_DOWNLOAD_SETTINGS); diagnosticsEnabledInput.checked = false; renderDiagnosticControls(); showSettingsStatus("Значения сброшены — нажмите «Сохранить»."); }); diagnosticButton.addEventListener("click", async () => { if (!diagnosticsEnabled) { renderDiagnosticControls(); return; } diagnosticButton.disabled = true; diagnosticStatus.className = "diagnostic-status"; diagnosticStatus.textContent = "Собираю диагностический лог…"; try { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tab = tabs && tabs[0]; if (!tab || !/^https:\/\/(?:[^/]+\.)?redgifs\.com\//i.test(tab.url || "")) { throw new Error("Откройте вкладку RedGifs и повторите."); } let pageDiagnostics = null; let pageError = null; let runtimeDiagnostics = null; let runtimeError = null; try { const response = await chrome.tabs.sendMessage(tab.id, { action: "captureExtensionDiagnostics" }); if (!response || !response.ok || !response.report) { throw new Error(response && response.error ? response.error : "Снимок страницы не получен."); } pageDiagnostics = response.report; } catch (error) { pageError = error && error.message ? error.message : String(error); } try { const response = await chrome.runtime.sendMessage({ action: "getDiagnosticLog" }); if (!response || !response.ok || !response.diagnostics) { throw new Error(response && response.error ? response.error : "Журнал фонового процесса не получен."); } runtimeDiagnostics = response.diagnostics; } catch (error) { runtimeError = error && error.message ? error.message : String(error); } if (!pageDiagnostics && !runtimeDiagnostics) { throw new Error("Диагностический лог не получен. Обновите вкладку RedGifs."); } const report = { schemaVersion: 2, generatedAt: new Date().toISOString(), extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null, activeTab: { id: tab.id, url: diagnosticPageUrl(tab.url) }, collectionErrors: { page: pageError, runtime: runtimeError }, pageDiagnostics, runtimeDiagnostics }; const saveResponse = await chrome.runtime.sendMessage({ action: "downloadDiagnosticReport", report }); if (!saveResponse || !saveResponse.ok || !saveResponse.result) { throw new Error(saveResponse && saveResponse.error ? saveResponse.error : "Фоновый процесс не смог сохранить диагностический лог."); } diagnosticStatus.className = "diagnostic-status success"; const runtimeEvents = runtimeDiagnostics && Array.isArray(runtimeDiagnostics.events) ? runtimeDiagnostics.events : []; const errorCount = runtimeEvents.filter((entry) => entry.level === "error").length; diagnosticStatus.textContent = "Лог сохранён: событий — " + runtimeEvents.length + ", ошибок — " + errorCount + "."; } catch (error) { diagnosticStatus.className = "diagnostic-status error"; diagnosticStatus.textContent = error && error.message ? error.message : String(error); } finally { diagnosticButton.disabled = !diagnosticsEnabled; } }); alternativeToggle.addEventListener("click", async () => { const opening = !panelIsExpanded(alternativePanel); alternativeToggle.setAttribute("aria-expanded", String(opening)); if (!opening) { stopPolling(); await setPanelExpanded(alternativePanel, false, { duration: 260, offset: 8, scale: 0.985 }); return; } await setPanelExpanded(alternativePanel, true, { duration: 300, offset: 10, scale: 0.98 }); if (!alternativeScanned && urlInput.value.trim()) { alternativeScanned = true; await resolveCurrentUrl(); } }); (async () => { writeSettingsForm(downloadSettings); renderDiagnosticControls(); try { const stored = await chrome.storage.local.get(["downloadSettings", "diagnosticsEnabled"]); downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, stored.downloadSettings || {}); diagnosticsEnabled = Boolean(stored.diagnosticsEnabled); writeSettingsForm(downloadSettings); renderDiagnosticControls(); const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tab = tabs && tabs[0]; if (tab && /^https:\/\/(?:[^/]+\.)?redgifs\.com\//i.test(tab.url || "")) { activeTabId = tab.id; activeTabUrl = tab.url; urlInput.value = tab.url; } renderSaveDestination(); renderSettingsPathPreview(); } catch { writeSettingsForm(downloadSettings); renderSaveDestination(); } })(); chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName !== "local") return; if (changes.downloadSettings) { downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, changes.downloadSettings.newValue || {}); writeSettingsForm(downloadSettings); renderSaveDestination(); } if (changes.diagnosticsEnabled) { diagnosticsEnabled = Boolean(changes.diagnosticsEnabled.newValue); diagnosticsEnabledInput.checked = diagnosticsEnabled; renderDiagnosticControls(); } });