Files

641 lines
24 KiB
JavaScript

"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();
}
});