Release RedGifs Video Downloader 0.6.21
This commit is contained in:
commit
3ffa17b6c4
23 files changed
+5398
No files matched your search
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.zip binary
|
||||
*.crx binary
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Local release artifacts
|
||||
*.zip
|
||||
*.crx
|
||||
*.pem
|
||||
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,61 @@
|
||||
REDGIFS VIDEO DOWNLOADER PROPRIETARY LICENSE
|
||||
Version 1.0, 14 August 2026
|
||||
|
||||
Copyright (c) 2026 OwnerLink. All rights reserved.
|
||||
|
||||
1. LIMITED END-USER LICENSE
|
||||
|
||||
Subject to this License, the copyright holder grants an individual end user a
|
||||
limited, revocable, non-exclusive, non-transferable license to install and run
|
||||
an unmodified official release of RedGifs Video Downloader solely for personal,
|
||||
non-commercial use and solely to save content that the end user owns or has
|
||||
explicit permission from the applicable rights holder to save.
|
||||
|
||||
2. NO OTHER RIGHTS GRANTED
|
||||
|
||||
Except for the limited permission in Section 1, no permission is granted to:
|
||||
|
||||
(a) copy, reproduce, modify, adapt, translate, merge, or create derivative works;
|
||||
(b) publish, distribute, sublicense, sell, rent, lease, host, mirror, or otherwise
|
||||
make the Software or any part of it available to another person;
|
||||
(c) fork, clone for redistribution, or maintain a public or private derivative;
|
||||
(d) reverse engineer, decompile, disassemble, or attempt to derive or reuse any
|
||||
component, except only to the extent such a restriction is prohibited by
|
||||
applicable law;
|
||||
(e) remove or alter copyright, license, attribution, or proprietary notices;
|
||||
(f) use the Software for commercial activity, bulk republication, infringement,
|
||||
unauthorized access, circumvention of DRM, or violation of third-party terms.
|
||||
|
||||
Possession of source code or a release archive does not grant any right beyond
|
||||
Section 1. No rights are granted by implication, estoppel, or otherwise.
|
||||
|
||||
3. THIRD-PARTY CONTENT AND SERVICES
|
||||
|
||||
The Software is unofficial and is not affiliated with, endorsed by, or sponsored
|
||||
by RedGIFS, Google, GitHub, or any content creator. This License grants no rights
|
||||
to third-party content, names, trademarks, services, or APIs. The end user is
|
||||
solely responsible for obtaining all necessary permissions and complying with
|
||||
applicable law and third-party terms.
|
||||
|
||||
4. TERMINATION
|
||||
|
||||
This License terminates automatically upon breach. On termination, the end user
|
||||
must stop using the Software and delete all copies in their possession or control.
|
||||
|
||||
5. NO WARRANTY
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF ANY
|
||||
KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. USE IS AT THE END USER'S SOLE RISK.
|
||||
|
||||
6. LIMITATION OF LIABILITY
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE COPYRIGHT HOLDER SHALL NOT
|
||||
BE LIABLE FOR ANY CLAIM, LOSS, DAMAGES, OR OTHER LIABILITY ARISING FROM OR RELATED
|
||||
TO THE SOFTWARE OR ITS USE, WHETHER IN CONTRACT, TORT, OR OTHERWISE.
|
||||
|
||||
7. WRITTEN PERMISSION
|
||||
|
||||
Any permission outside Section 1 must be granted in a separate written agreement
|
||||
signed by the copyright holder. Requests may be submitted through the official
|
||||
GitHub repository maintained by OwnerLink.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Privacy Policy
|
||||
|
||||
Effective date: 14 August 2026
|
||||
|
||||
RedGifs Video Downloader is designed to work locally in the user's Chromium-based browser.
|
||||
|
||||
## Data handled by the extension
|
||||
|
||||
The extension may process the following data when the user visits RedGIFS:
|
||||
|
||||
- the current RedGIFS page URL;
|
||||
- visible page elements and media metadata needed to identify a video;
|
||||
- RedGIFS media URLs and temporary API authorization required by the site;
|
||||
- download settings and the stable IDs of successfully downloaded videos;
|
||||
- diagnostic events, DOM geometry, HTTP statuses and error messages.
|
||||
|
||||
## How data is used
|
||||
|
||||
This data is used only to find requested media, assemble it locally, save it through the browser download manager, remember completed downloads, and generate a diagnostic log when requested.
|
||||
|
||||
## Storage and sharing
|
||||
|
||||
- The project has no developer-operated server.
|
||||
- No analytics or telemetry is collected.
|
||||
- No data is sold or shared by the developer.
|
||||
- Settings and downloaded-video IDs remain in browser extension storage.
|
||||
- Temporary diagnostic events remain in session storage and are exported only when the user clicks the diagnostic-log button.
|
||||
- Media is not uploaded to a third-party service by this extension.
|
||||
|
||||
Normal communication with RedGIFS and its content-delivery infrastructure remains subject to RedGIFS' own privacy policy and terms.
|
||||
|
||||
## Diagnostic redaction
|
||||
|
||||
The diagnostic exporter removes URL query strings and fragments and replaces known token, authorization, cookie, credential, password and secret fields with `[redacted]`. Video bodies and API response bodies are not included. Users should still review a diagnostic file before sharing it.
|
||||
|
||||
## Removal
|
||||
|
||||
Users can erase extension settings and history by removing the extension or clearing its stored data in the browser.
|
||||
|
||||
## Contact
|
||||
|
||||
Privacy questions may be submitted through the official GitHub repository maintained by OwnerLink.
|
||||
@@ -0,0 +1,118 @@
|
||||
# RedGifs Video Downloader
|
||||
|
||||

|
||||
|
||||
Локальное расширение Chromium для сохранения разрешённых пользователю видео с RedGIFs. Расширение добавляет кнопку загрузки непосредственно на карточки, показывает ход выполнения и собирает HLS-потоки на устройстве пользователя.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Это неофициальный проект, не связанный с RedGIFS, Google или GitHub. Он предназначен только для совершеннолетних пользователей и только для материалов, которые пользователь создал сам либо получил явное разрешение сохранить. Условия RedGIFS могут ограничивать скачивание контента; ответственность за соблюдение условий сайта и прав авторов несёт пользователь.
|
||||
|
||||
## Возможности
|
||||
|
||||
- кнопка загрузки на обнаруженных карточках, в ленте и полноэкранном просмотре;
|
||||
- автоматический выбор HD MP4 с резервными вариантами;
|
||||
- локальная обработка MP4, GIF и HLS без стороннего сервера;
|
||||
- наглядное уведомление с ходом загрузки и пятисекундным обратным отсчётом;
|
||||
- отметка уже скачанных роликов по стабильному RedGIFS ID;
|
||||
- повторная загрузка через оранжевую кнопку при наведении на зелёную галочку;
|
||||
- понятные имена файлов из метаданных ролика;
|
||||
- пользовательская подпапка внутри `Загрузок`;
|
||||
- необязательная группировка по имени профиля;
|
||||
- резервный сканер видимых роликов в popup;
|
||||
- диагностический журнал с удалением токенов, cookie и других секретов;
|
||||
- без регистрации, платного счётчика, аналитики и телеметрии.
|
||||
|
||||
## Установка
|
||||
|
||||
### Из GitHub Release
|
||||
|
||||
1. Скачайте архив `RedGifs-Video-Downloader-v0.6.21.zip` из последнего Release.
|
||||
2. Распакуйте архив в постоянную папку. Не удаляйте её после установки.
|
||||
3. Откройте `chrome://extensions` в Chrome, Chromium, Brave или другом совместимом браузере.
|
||||
4. Включите **Режим разработчика**.
|
||||
5. Нажмите **Загрузить распакованное расширение**.
|
||||
6. Выберите распакованную папку, внутри которой находится `manifest.json`.
|
||||
|
||||
Расширение распространяется вручную: его публикация в Chrome Web Store не планируется из-за правил магазина для расширений, основная функция которых относится к сайтам с контентом для взрослых.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Откройте страницу ролика, профиль, нишу или ленту RedGIFS.
|
||||
2. На распознанной видеокарточке появится круглая розовая кнопка загрузки.
|
||||
3. Нажмите её и дождитесь завершения. Страница покажет текущий этап и ход загрузки.
|
||||
4. После успешного сохранения кнопка станет зелёной. При наведении появится действие повторной загрузки.
|
||||
|
||||
Если кнопка не появилась, откройте popup расширения и запустите резервное сканирование видимых роликов. При бесконечной ленте новые карточки обрабатываются по мере прокрутки.
|
||||
|
||||
## Настройки
|
||||
|
||||
Настройки находятся под шестерёнкой в popup и по умолчанию свёрнуты.
|
||||
|
||||
- **Подпапка внутри Загрузок** — относительный путь, например `Видео/RedGifs`.
|
||||
- **Группировать по профилям** — добавляет к пути имя профиля из URL или метаданных карточки.
|
||||
- **Всегда спрашивать место сохранения** — включает системный диалог браузера.
|
||||
|
||||
Пример итогового пути:
|
||||
|
||||
```text
|
||||
Загрузки/RedGifs/example-profile/Название ролика.mp4
|
||||
```
|
||||
|
||||
Фактическая корневая папка определяется настройками загрузок браузера. Расширения Chromium не могут произвольно сохранять файлы за пределами разрешённого браузером каталога.
|
||||
|
||||
## Приватность и безопасность
|
||||
|
||||
- видео обрабатывается локально в браузере;
|
||||
- сторонние серверы проекта отсутствуют;
|
||||
- аккаунт и email не требуются;
|
||||
- аналитика и телеметрия отсутствуют;
|
||||
- сетевой доступ ограничен `redgifs.com` и его поддоменами;
|
||||
- настройки и история скачанных ID хранятся в локальном хранилище расширения;
|
||||
- диагностический журнал создаётся только по команде пользователя;
|
||||
- чувствительные заголовки, токены, cookie, query-параметры и hash удаляются или заменяются на `[redacted]`.
|
||||
|
||||
Подробнее: [PRIVACY.md](PRIVACY.md).
|
||||
|
||||
## Разрешения браузера
|
||||
|
||||
| Разрешение | Назначение |
|
||||
| --- | --- |
|
||||
| `activeTab`, `scripting` | обнаружение роликов и отображение интерфейса на открытой вкладке RedGIFS |
|
||||
| `downloads` | передача готового файла менеджеру загрузок браузера |
|
||||
| `offscreen` | локальная сборка Blob/HLS вне жизненного цикла popup |
|
||||
| `storage` | настройки, история скачанных ID и временная диагностика |
|
||||
| `tabs` | связь popup и service worker с открытой вкладкой |
|
||||
| `https://redgifs.com/*`, `https://*.redgifs.com/*` | получение разрешённых медиа и метаданных только с RedGIFS |
|
||||
|
||||
## Поддерживаемые форматы
|
||||
|
||||
- прямой MP4;
|
||||
- GIF;
|
||||
- HLS с выбором потока наибольшего доступного разрешения;
|
||||
- AES-128 HLS.
|
||||
|
||||
SAMPLE-AES и DRM не поддерживаются и не обходятся. Очень большие HLS-ролики могут потребовать заметный объём оперативной памяти, поскольку сегменты собираются локально.
|
||||
|
||||
## Диагностика
|
||||
|
||||
Кнопка **Скачать диагностический лог** находится в popup. Журнал может содержать сведения о DOM-структуре страницы, расположении карточек и кнопок, этапах работы расширения, HTTP-статусах и сетевых ошибках. Он не содержит тело видео и намеренно очищается от известных секретов.
|
||||
|
||||
Перед публикацией диагностического файла всё равно рекомендуется просмотреть его вручную.
|
||||
|
||||
## Правовой статус
|
||||
|
||||
Исходный код и релизные сборки предоставляются по проприетарной лицензии **All Rights Reserved**. Разрешена только установка и запуск неизменённого официального релиза для личного некоммерческого использования. Копирование, изменение, форки, публикация зеркал, перераспространение, продажа и создание производных работ запрещены без предварительного письменного разрешения правообладателя.
|
||||
|
||||
Полный текст: [LICENSE](LICENSE).
|
||||
|
||||
Публичная доступность файлов на GitHub сама по себе не предоставляет дополнительных прав. Технически GitHub позволяет создать fork любого публичного репозитория, однако такой fork или иное копирование может нарушать условия этой лицензии.
|
||||
|
||||
## Версия
|
||||
|
||||
Текущая версия: **0.6.21**.
|
||||
|
||||
История релиза: [RELEASE_NOTES.md](RELEASE_NOTES.md).
|
||||
|
||||
## Отказ от гарантий
|
||||
|
||||
Программа предоставляется «как есть», без гарантий работоспособности, доступности RedGIFS, соответствия сторонним условиям или сохранности данных. Структура сайта и API могут измениться без предупреждения.
|
||||
@@ -0,0 +1,38 @@
|
||||
# RedGifs Video Downloader 0.6.21
|
||||
|
||||
Первый публичный релиз независимого локального загрузчика для Chromium.
|
||||
|
||||
## Главное
|
||||
|
||||
- Кнопки загрузки в карточках, лентах и полноэкранном режиме.
|
||||
- Устойчивое позиционирование кнопок при виртуализации и бесконечной прокрутке.
|
||||
- Автоматическое обнаружение новых карточек без накопления дубликатов.
|
||||
- Локальное сохранение MP4, GIF и HLS без внешнего сервера.
|
||||
- Единое уведомление с ходом загрузки, зелёной полосой прогресса и обратным отсчётом после завершения.
|
||||
- Синхронная зелёная отметка только после фактического завершения записи файла.
|
||||
- История скачанных RedGIFS ID и оранжевая кнопка повторной загрузки.
|
||||
- Имена файлов из настоящего названия/описания ролика.
|
||||
- Группировка файлов по профилям, включая карточки в общей ленте.
|
||||
- Сворачиваемые настройки в popup с плавной анимацией.
|
||||
- Резервное сканирование видимых роликов с превью.
|
||||
- Экспорт диагностического журнала с очисткой известных секретов.
|
||||
- Новый комплект иконок и название RedGifs Video Downloader.
|
||||
|
||||
## Установка
|
||||
|
||||
1. Скачайте `RedGifs-Video-Downloader-v0.6.21.zip`.
|
||||
2. Распакуйте архив.
|
||||
3. Откройте `chrome://extensions`.
|
||||
4. Включите режим разработчика.
|
||||
5. Нажмите «Загрузить распакованное расширение» и выберите распакованную папку.
|
||||
|
||||
## Важное ограничение
|
||||
|
||||
Используйте расширение только для собственных материалов или контента, на сохранение которого у вас есть явное разрешение правообладателя. Проект не обходит DRM и не предоставляет прав на чужой контент.
|
||||
|
||||
## Контрольная сумма
|
||||
|
||||
```text
|
||||
RedGifs-Video-Downloader-v0.6.21.zip
|
||||
SHA-256: a81ea275639fcbf56f1224c32c02a1db45e7d618f701068b277881aaea7efba7
|
||||
```
|
||||
+1130
File diff suppressed because it is too large.
Load diff
+1604
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 752 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="RedGifs Video Downloader">
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="20" y1="15" x2="108" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ff5a7c"/>
|
||||
<stop offset="0.55" stop-color="#ee3e67"/>
|
||||
<stop offset="1" stop-color="#c9234d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine" x1="31" y1="17" x2="81" y2="86" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ffffff" stop-opacity="0.24"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#350814" flood-opacity="0.36"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="7" y="5" width="114" height="114" rx="32" fill="url(#surface)" filter="url(#shadow)"/>
|
||||
<rect x="8.5" y="6.5" width="111" height="111" rx="30.5" fill="none" stroke="#ff9caf" stroke-width="3" stroke-opacity="0.72"/>
|
||||
<path d="M22 44C30 21 49 10 72 10c15 0 28 5 38 14C91 19 64 24 43 43c-10 9-17 20-22 32-3-10-3-21 1-31Z" fill="url(#shine)"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 30v48"/>
|
||||
<path d="m45 61 19 19 19-19"/>
|
||||
<path d="M39 96h50"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "RedGifs Video Downloader",
|
||||
"version": "0.6.21",
|
||||
"description": "Downloads RedGifs media directly and processes HLS locally. No account or telemetry.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"downloads",
|
||||
"offscreen",
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://redgifs.com/*",
|
||||
"https://*.redgifs.com/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://redgifs.com/*",
|
||||
"https://*.redgifs.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_title": "RedGifs Video Downloader",
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Local HLS worker</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="offscreen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
"use strict";
|
||||
|
||||
const activeJobs = new Set();
|
||||
|
||||
function reportDiagnostic(level, area, event, details) {
|
||||
try {
|
||||
const pending = chrome.runtime.sendMessage({
|
||||
target: "background",
|
||||
action: "diagnosticEvent",
|
||||
level,
|
||||
area,
|
||||
event,
|
||||
details: details || {}
|
||||
});
|
||||
if (pending && typeof pending.catch === "function") pending.catch(() => {});
|
||||
} catch {
|
||||
// Diagnostics must never interrupt a download.
|
||||
}
|
||||
}
|
||||
|
||||
function shouldLogNetworkResponse(url, response) {
|
||||
if (!response || !response.ok) return true;
|
||||
try {
|
||||
return /\.(?:m3u8|mp4|webm|gif)$/i.test(new URL(url).pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRedgifsHost(hostname) {
|
||||
const host = String(hostname || "").toLowerCase();
|
||||
return host === "redgifs.com" || host.endsWith(".redgifs.com");
|
||||
}
|
||||
|
||||
function trustedUrl(value, baseUrl) {
|
||||
const parsed = new URL(String(value || "").trim(), baseUrl);
|
||||
if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) {
|
||||
throw new Error("HLS содержит ссылку за пределами RedGifs: " + parsed.hostname);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function safeFilename(value) {
|
||||
const cleaned = String(value || "redgifs-video")
|
||||
.replace(/^Poster for\s+/i, "")
|
||||
.replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "")
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.trim();
|
||||
return (cleaned || "redgifs-video").slice(0, 120);
|
||||
}
|
||||
|
||||
function safeFolderPath(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)
|
||||
.map((part) => part.slice(0, 80))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function outputFilename(folder, base, extension) {
|
||||
const filename = safeFilename(base) + "." + extension;
|
||||
const safeFolder = safeFolderPath(folder);
|
||||
return safeFolder ? safeFolder + "/" + filename : filename;
|
||||
}
|
||||
|
||||
async function report(jobId, state, progress, status, extra) {
|
||||
try {
|
||||
await chrome.runtime.sendMessage(Object.assign({
|
||||
target: "background",
|
||||
action: "hlsProgress",
|
||||
jobId,
|
||||
state,
|
||||
progress,
|
||||
status
|
||||
}, extra || {}));
|
||||
} catch {
|
||||
// The service worker will be woken by the next progress update.
|
||||
}
|
||||
}
|
||||
|
||||
function validReferrer(value) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && isRedgifsHost(parsed.hostname) ? parsed.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMedia(url, init, sourceUrl) {
|
||||
const options = Object.assign({
|
||||
cache: "no-store",
|
||||
credentials: "omit"
|
||||
}, init || {});
|
||||
const referrer = validReferrer(sourceUrl);
|
||||
if (referrer) {
|
||||
options.referrer = referrer;
|
||||
options.referrerPolicy = "no-referrer-when-downgrade";
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (shouldLogNetworkResponse(url, response)) {
|
||||
reportDiagnostic(response.ok ? "info" : "warn", "network.media", "response", {
|
||||
url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
usedReferrer: Boolean(referrer),
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (firstError) {
|
||||
reportDiagnostic(referrer ? "warn" : "error", "network.media", "request-failed", {
|
||||
url,
|
||||
usedReferrer: Boolean(referrer),
|
||||
durationMs: Date.now() - startedAt,
|
||||
willRetryWithoutReferrer: Boolean(referrer),
|
||||
error: { name: firstError && firstError.name, message: firstError && firstError.message }
|
||||
});
|
||||
if (!referrer) throw firstError;
|
||||
delete options.referrer;
|
||||
delete options.referrerPolicy;
|
||||
const retryStartedAt = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (shouldLogNetworkResponse(url, response)) {
|
||||
reportDiagnostic(response.ok ? "info" : "warn", "network.media", "retry-response", {
|
||||
url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
durationMs: Date.now() - retryStartedAt
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (retryError) {
|
||||
reportDiagnostic("error", "network.media", "retry-failed", {
|
||||
url,
|
||||
durationMs: Date.now() - retryStartedAt,
|
||||
error: { name: retryError && retryError.name, message: retryError && retryError.message }
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchText(url, sourceUrl) {
|
||||
const response = await fetchMedia(url, {
|
||||
headers: { Accept: "application/vnd.apple.mpegurl, application/x-mpegURL, text/plain, */*" }
|
||||
}, sourceUrl);
|
||||
if (!response.ok) throw new Error("Не удалось получить HLS playlist: HTTP " + response.status);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function parseAttributes(value) {
|
||||
const result = {};
|
||||
const matcher = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/gi;
|
||||
let match;
|
||||
while ((match = matcher.exec(value)) !== null) {
|
||||
let item = match[2] || "";
|
||||
if (item.startsWith("\"") && item.endsWith("\"")) item = item.slice(1, -1);
|
||||
result[match[1].toUpperCase()] = item;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function playlistLines(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function masterVariants(text, baseUrl) {
|
||||
const lines = playlistLines(text);
|
||||
const variants = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.startsWith("#EXT-X-STREAM-INF:")) continue;
|
||||
const attributes = parseAttributes(line.slice("#EXT-X-STREAM-INF:".length));
|
||||
let next = index + 1;
|
||||
while (next < lines.length && lines[next].startsWith("#")) next += 1;
|
||||
if (next >= lines.length) continue;
|
||||
const resolution = String(attributes.RESOLUTION || "").match(/(\d+)x(\d+)/i);
|
||||
variants.push({
|
||||
url: trustedUrl(lines[next], baseUrl),
|
||||
bandwidth: Number(attributes.BANDWIDTH) || 0,
|
||||
height: resolution ? Number(resolution[2]) : 0
|
||||
});
|
||||
}
|
||||
return variants.sort((left, right) => {
|
||||
if (right.height !== left.height) return right.height - left.height;
|
||||
return right.bandwidth - left.bandwidth;
|
||||
});
|
||||
}
|
||||
|
||||
function parseByteRange(specification, url, offsets) {
|
||||
if (!specification) return null;
|
||||
const match = String(specification).match(/^(\d+)(?:@(\d+))?$/);
|
||||
if (!match) throw new Error("Неподдерживаемый EXT-X-BYTERANGE: " + specification);
|
||||
const length = Number(match[1]);
|
||||
const start = match[2] == null ? (offsets.get(url) || 0) : Number(match[2]);
|
||||
const end = start + length - 1;
|
||||
offsets.set(url, end + 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function parseMediaPlaylist(text, baseUrl) {
|
||||
const lines = playlistLines(text);
|
||||
const segments = [];
|
||||
const offsets = new Map();
|
||||
let mediaSequence = 0;
|
||||
let pendingDuration = null;
|
||||
let pendingRange = null;
|
||||
let currentKey = null;
|
||||
let currentMap = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#EXT-X-MEDIA-SEQUENCE:")) {
|
||||
mediaSequence = Number(line.slice("#EXT-X-MEDIA-SEQUENCE:".length)) || 0;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
const match = line.match(/^#EXTINF:([\d.]+)/);
|
||||
pendingDuration = match ? Number(match[1]) : null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#EXT-X-BYTERANGE:")) {
|
||||
pendingRange = line.slice("#EXT-X-BYTERANGE:".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#EXT-X-KEY:")) {
|
||||
const attributes = parseAttributes(line.slice("#EXT-X-KEY:".length));
|
||||
if (String(attributes.METHOD || "").toUpperCase() === "NONE") {
|
||||
currentKey = null;
|
||||
} else {
|
||||
currentKey = {
|
||||
method: String(attributes.METHOD || "").toUpperCase(),
|
||||
url: attributes.URI ? trustedUrl(attributes.URI, baseUrl) : null,
|
||||
iv: attributes.IV || null
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#EXT-X-MAP:")) {
|
||||
const attributes = parseAttributes(line.slice("#EXT-X-MAP:".length));
|
||||
const mapUrl = trustedUrl(attributes.URI, baseUrl);
|
||||
currentMap = {
|
||||
url: mapUrl,
|
||||
range: parseByteRange(attributes.BYTERANGE, mapUrl, offsets)
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#")) continue;
|
||||
|
||||
const segmentUrl = trustedUrl(line, baseUrl);
|
||||
segments.push({
|
||||
url: segmentUrl,
|
||||
duration: pendingDuration,
|
||||
range: parseByteRange(pendingRange, segmentUrl, offsets),
|
||||
sequence: mediaSequence + segments.length,
|
||||
key: currentKey ? Object.assign({}, currentKey) : null,
|
||||
map: currentMap ? {
|
||||
url: currentMap.url,
|
||||
range: currentMap.range ? Object.assign({}, currentMap.range) : null
|
||||
} : null
|
||||
});
|
||||
pendingDuration = null;
|
||||
pendingRange = null;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function fetchBytes(url, range, sourceUrl) {
|
||||
const headers = { Accept: "*/*" };
|
||||
if (range) headers.Range = "bytes=" + range.start + "-" + range.end;
|
||||
const response = await fetchMedia(url, { headers }, sourceUrl);
|
||||
if (!response.ok) throw new Error("Сегмент HLS: HTTP " + response.status);
|
||||
let bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (range && response.status !== 206) {
|
||||
const expected = range.end - range.start + 1;
|
||||
if (bytes.byteLength >= range.end + 1) {
|
||||
bytes = bytes.slice(range.start, range.end + 1);
|
||||
} else if (bytes.byteLength !== expected) {
|
||||
throw new Error("Сервер проигнорировал byte-range сегмента.");
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function makeIv(explicitIv, sequence) {
|
||||
const iv = new Uint8Array(16);
|
||||
if (explicitIv) {
|
||||
let hex = String(explicitIv).replace(/^0x/i, "").replace(/[^0-9a-f]/gi, "");
|
||||
hex = hex.padStart(32, "0").slice(-32);
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
iv[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
return iv;
|
||||
}
|
||||
let value = BigInt(sequence);
|
||||
for (let index = 15; index >= 0; index -= 1) {
|
||||
iv[index] = Number(value & 255n);
|
||||
value >>= 8n;
|
||||
}
|
||||
return iv;
|
||||
}
|
||||
|
||||
async function decryptSegment(bytes, keyDescriptor, sequence, sourceUrl, keyCache) {
|
||||
if (!keyDescriptor) return bytes;
|
||||
if (keyDescriptor.method !== "AES-128" || !keyDescriptor.url) {
|
||||
throw new Error("Неподдерживаемое шифрование HLS: " + (keyDescriptor.method || "unknown"));
|
||||
}
|
||||
let cryptoKey = keyCache.get(keyDescriptor.url);
|
||||
if (!cryptoKey) {
|
||||
const rawKey = await fetchBytes(keyDescriptor.url, null, sourceUrl);
|
||||
if (rawKey.byteLength !== 16) throw new Error("Некорректный AES-128 ключ HLS.");
|
||||
cryptoKey = await crypto.subtle.importKey("raw", rawKey, { name: "AES-CBC" }, false, ["decrypt"]);
|
||||
keyCache.set(keyDescriptor.url, cryptoKey);
|
||||
}
|
||||
const decrypted = await crypto.subtle.decrypt({
|
||||
name: "AES-CBC",
|
||||
iv: makeIv(keyDescriptor.iv, sequence)
|
||||
}, cryptoKey, bytes);
|
||||
return new Uint8Array(decrypted);
|
||||
}
|
||||
|
||||
function mapIdentity(map, key) {
|
||||
if (!map) return "";
|
||||
const range = map.range ? map.range.start + "-" + map.range.end : "all";
|
||||
const keyPart = key ? key.url + ":" + (key.iv || "") : "plain";
|
||||
return map.url + "|" + range + "|" + keyPart;
|
||||
}
|
||||
|
||||
function looksLikeFragmentedMp4(chunks, segments) {
|
||||
if (segments.some((segment) => Boolean(segment.map))) return true;
|
||||
if (segments.some((segment) => /\.(?:m4s|mp4)(?:$|\?)/i.test(segment.url))) return true;
|
||||
const first = chunks[0];
|
||||
if (!first || first.length < 8) return false;
|
||||
const box = String.fromCharCode(first[4], first[5], first[6], first[7]);
|
||||
return ["ftyp", "styp", "moof"].includes(box);
|
||||
}
|
||||
|
||||
async function downloadBlob(blobUrl, filename, saveAs) {
|
||||
try {
|
||||
if (chrome.downloads && chrome.downloads.download) {
|
||||
return await chrome.downloads.download({ url: blobUrl, filename, saveAs });
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the service worker, which has the downloads permission.
|
||||
}
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
target: "background",
|
||||
action: "downloadBlobUrl",
|
||||
blobUrl,
|
||||
filename,
|
||||
saveAs
|
||||
});
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(response && response.error ? response.error : "Chrome не принял локальный Blob.");
|
||||
}
|
||||
return response.downloadId;
|
||||
}
|
||||
|
||||
async function downloadTextFile(message) {
|
||||
const text = String(message && message.text || "");
|
||||
if (!text || text.length > 6 * 1024 * 1024) {
|
||||
throw new Error(text ? "Диагностический лог слишком большой." : "Диагностический лог пуст.");
|
||||
}
|
||||
const filename = String(message.filename || "redgifs-diagnostic-log.json")
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.slice(0, 180) || "redgifs-diagnostic-log.json";
|
||||
const blob = new Blob([text], { type: message.mimeType || "application/json;charset=utf-8" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const downloadId = await downloadBlob(blobUrl, filename, message.saveAs !== false);
|
||||
return { downloadId, filename };
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDirect(message) {
|
||||
const jobId = message.jobId;
|
||||
activeJobs.add(jobId);
|
||||
try {
|
||||
const mediaUrl = trustedUrl(message.mediaUrl);
|
||||
await report(jobId, "running", 1, "Подключение к CDN RedGifs…");
|
||||
const response = await fetchMedia(mediaUrl, {
|
||||
headers: {
|
||||
Accept: "video/webm,video/ogg,video/*;q=0.9,image/gif;q=0.8,*/*;q=0.5"
|
||||
}
|
||||
}, message.sourceUrl);
|
||||
if (!response.ok) throw new Error("Загрузка видео: HTTP " + response.status);
|
||||
|
||||
const total = Number(response.headers.get("content-length")) || 0;
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
let lastReportedProgress = -1;
|
||||
if (response.body && response.body.getReader) {
|
||||
const reader = response.body.getReader();
|
||||
for (;;) {
|
||||
const part = await reader.read();
|
||||
if (part.done) break;
|
||||
chunks.push(part.value);
|
||||
received += part.value.byteLength;
|
||||
const progress = total ? Math.min(94, Math.round((received / total) * 94)) : Math.min(90, 2 + Math.round(chunks.length / 2));
|
||||
if (progress !== lastReportedProgress) {
|
||||
lastReportedProgress = progress;
|
||||
await report(jobId, "running", progress, total ? "Загрузка · " + Math.round(received / 1048576) + "/" + Math.round(total / 1048576) + " МБ" : "Загружено · " + Math.round(received / 1048576) + " МБ");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
chunks.push(bytes);
|
||||
received = bytes.byteLength;
|
||||
}
|
||||
|
||||
if (!received) throw new Error("RedGifs вернул пустой файл.");
|
||||
await report(jobId, "running", 96, "Подготовка локального файла…");
|
||||
const extension = String(message.extension || "mp4").replace(/[^a-z0-9]/gi, "") || "mp4";
|
||||
const filename = outputFilename(message.outputFolder, message.filenameBase, extension);
|
||||
const mimeType = response.headers.get("content-type") || (extension === "gif" ? "image/gif" : "video/mp4");
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const downloadId = await downloadBlob(blobUrl, filename, Boolean(message.saveAs));
|
||||
await report(jobId, "saving", 98, "Сохранение файла…", {
|
||||
filename,
|
||||
downloadId
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||||
}
|
||||
} catch (error) {
|
||||
reportDiagnostic("error", "offscreen.direct", "job-failed", {
|
||||
jobId,
|
||||
mediaUrl: message.mediaUrl,
|
||||
error: { name: error && error.name, message: error && error.message }
|
||||
});
|
||||
await report(jobId, "error", 0, "Ошибка загрузки", {
|
||||
error: error && error.message ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHls(message) {
|
||||
const jobId = message.jobId;
|
||||
activeJobs.add(jobId);
|
||||
try {
|
||||
let playlistUrl = trustedUrl(message.playlistUrl);
|
||||
let playlistText = null;
|
||||
let variants = [];
|
||||
|
||||
await report(jobId, "running", 1, "Получение HLS playlist…");
|
||||
for (let depth = 0; depth < 3; depth += 1) {
|
||||
playlistText = await fetchText(playlistUrl, message.sourceUrl);
|
||||
variants = masterVariants(playlistText, playlistUrl);
|
||||
if (!variants.length) break;
|
||||
playlistUrl = variants[0].url;
|
||||
await report(jobId, "running", 3, "Выбран лучший HLS-поток" + (variants[0].height ? " · " + variants[0].height + "p" : ""));
|
||||
}
|
||||
|
||||
const segments = parseMediaPlaylist(playlistText, playlistUrl);
|
||||
if (!segments.length) throw new Error("В HLS playlist не найдено сегментов.");
|
||||
await report(jobId, "running", 5, "Сегментов: " + segments.length);
|
||||
|
||||
const chunks = [];
|
||||
const downloadedMaps = new Set();
|
||||
const keyCache = new Map();
|
||||
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (segment.map) {
|
||||
const identity = mapIdentity(segment.map, segment.key);
|
||||
if (!downloadedMaps.has(identity)) {
|
||||
let mapBytes = await fetchBytes(segment.map.url, segment.map.range, message.sourceUrl);
|
||||
mapBytes = await decryptSegment(mapBytes, segment.key, segment.sequence, message.sourceUrl, keyCache);
|
||||
chunks.push(mapBytes);
|
||||
downloadedMaps.add(identity);
|
||||
}
|
||||
}
|
||||
|
||||
let segmentBytes = await fetchBytes(segment.url, segment.range, message.sourceUrl);
|
||||
segmentBytes = await decryptSegment(segmentBytes, segment.key, segment.sequence, message.sourceUrl, keyCache);
|
||||
chunks.push(segmentBytes);
|
||||
const progress = 5 + Math.round(((index + 1) / segments.length) * 86);
|
||||
await report(jobId, "running", progress, "Загрузка сегментов · " + (index + 1) + "/" + segments.length);
|
||||
}
|
||||
|
||||
await report(jobId, "running", 94, "Сборка локального файла…");
|
||||
const fragmentedMp4 = looksLikeFragmentedMp4(chunks, segments);
|
||||
const extension = fragmentedMp4 ? "mp4" : "ts";
|
||||
const mimeType = fragmentedMp4 ? "video/mp4" : "video/mp2t";
|
||||
const filename = outputFilename(message.outputFolder, message.filenameBase, extension);
|
||||
const blob = new Blob(chunks, { type: mimeType });
|
||||
if (!blob.size) throw new Error("Собранный файл пуст.");
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const downloadId = await downloadBlob(blobUrl, filename, Boolean(message.saveAs));
|
||||
await report(jobId, "saving", 98, "Сохранение файла…", {
|
||||
filename,
|
||||
downloadId
|
||||
});
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
|
||||
}
|
||||
} catch (error) {
|
||||
reportDiagnostic("error", "offscreen.hls", "job-failed", {
|
||||
jobId,
|
||||
playlistUrl: message.playlistUrl,
|
||||
error: { name: error && error.name, message: error && error.message }
|
||||
});
|
||||
await report(jobId, "error", 0, "Ошибка HLS", {
|
||||
error: error && error.message ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || message.target !== "offscreen"
|
||||
|| !["downloadHls", "downloadDirect", "downloadTextFile"].includes(message.action)) return false;
|
||||
if (message.action === "downloadTextFile") {
|
||||
downloadTextFile(message).then((result) => {
|
||||
sendResponse({ ok: true, result });
|
||||
}).catch((error) => {
|
||||
reportDiagnostic("error", "offscreen.diagnostic", "download-failed", {
|
||||
filename: message.filename,
|
||||
error: { name: error && error.name, message: error && error.message }
|
||||
});
|
||||
sendResponse({ ok: false, error: error && error.message ? error.message : String(error) });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (activeJobs.has(message.jobId)) {
|
||||
sendResponse({ ok: false, error: "Эта задача уже выполняется." });
|
||||
return false;
|
||||
}
|
||||
sendResponse({ ok: true });
|
||||
setTimeout(() => {
|
||||
if (message.action === "downloadHls") {
|
||||
runHls(message);
|
||||
} else {
|
||||
runDirect(message);
|
||||
}
|
||||
}, 0);
|
||||
return false;
|
||||
});
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0b0f16;
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 92% 0, rgba(240, 68, 104, 0.18), transparent 34%),
|
||||
linear-gradient(150deg, #111722, #080b10 70%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
width: min(680px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 52px 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-mark {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ff5277, #cf2851);
|
||||
box-shadow: 0 14px 38px rgba(240, 68, 104, 0.25);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin-top: 4px;
|
||||
color: #8995a9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: 25px;
|
||||
border: 1px solid #263143;
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 21, 31, 0.92);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.settings-card > label:first-child {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #d9e0ea;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px 13px;
|
||||
border: 1px solid #344157;
|
||||
border-radius: 11px;
|
||||
outline: none;
|
||||
background: #0c1119;
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
border-color: #f04468;
|
||||
box-shadow: 0 0 0 3px rgba(240, 68, 104, 0.13);
|
||||
}
|
||||
|
||||
.help {
|
||||
margin: 9px 0 23px;
|
||||
color: #7f8a9e;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
code {
|
||||
color: #d9e0ea;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid #232d3d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-top: 2px;
|
||||
accent-color: #f04468;
|
||||
}
|
||||
|
||||
.check-row strong,
|
||||
.check-row small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.check-row strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.check-row small {
|
||||
margin-top: 4px;
|
||||
color: #7f8a9e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-top: 18px;
|
||||
padding: 13px;
|
||||
border: 1px solid #2b374a;
|
||||
border-radius: 11px;
|
||||
background: #0c1119;
|
||||
}
|
||||
|
||||
.preview span,
|
||||
.preview strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview span {
|
||||
margin-bottom: 5px;
|
||||
color: #748096;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.preview strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: #cdd5e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 9px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 15px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #f04468;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #202a39;
|
||||
color: #cbd3df;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 18px;
|
||||
margin-top: 12px;
|
||||
color: #69dda9;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройки RedGifs Video Downloader</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="settings-shell">
|
||||
<header>
|
||||
<div class="settings-mark">RG</div>
|
||||
<div>
|
||||
<h1>Настройки RedGifs Video Downloader</h1>
|
||||
<p>Папки сохранения и поведение загрузчика</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="settings-card">
|
||||
<label for="baseFolder">Подпапка внутри «Загрузок»</label>
|
||||
<input id="baseFolder" type="text" maxlength="100" placeholder="RedGifs" autocomplete="off">
|
||||
<p class="help">Можно использовать вложенный путь, например <code>Видео/RedGifs</code>. Абсолютную папку расширение задавать не может — корневая папка определяется настройками Chrome.</p>
|
||||
|
||||
<label class="check-row">
|
||||
<input id="groupByProfile" type="checkbox">
|
||||
<span>
|
||||
<strong>Группировать по профилям</strong>
|
||||
<small>Для страницы <code>/users/name</code> файл попадёт в подпапку <code>name</code>.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="check-row">
|
||||
<input id="saveAs" type="checkbox">
|
||||
<span>
|
||||
<strong>Всегда спрашивать место сохранения</strong>
|
||||
<small>Chrome покажет системный диалог для каждого файла.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="preview">
|
||||
<span>Пример пути</span>
|
||||
<strong id="pathPreview">Загрузки/RedGifs/profile/video.mp4</strong>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="resetButton" class="secondary" type="button">По умолчанию</button>
|
||||
<button id="saveButton" class="primary" type="button">Сохранить</button>
|
||||
</div>
|
||||
<div id="status" class="status" aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
baseFolder: "RedGifs",
|
||||
groupByProfile: true,
|
||||
saveAs: false
|
||||
};
|
||||
|
||||
const baseFolderInput = document.getElementById("baseFolder");
|
||||
const groupByProfileInput = document.getElementById("groupByProfile");
|
||||
const saveAsInput = document.getElementById("saveAs");
|
||||
const pathPreview = document.getElementById("pathPreview");
|
||||
const saveButton = document.getElementById("saveButton");
|
||||
const resetButton = document.getElementById("resetButton");
|
||||
const statusBox = document.getElementById("status");
|
||||
|
||||
function cleanPath(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 readForm() {
|
||||
return {
|
||||
baseFolder: cleanPath(baseFolderInput.value),
|
||||
groupByProfile: groupByProfileInput.checked,
|
||||
saveAs: saveAsInput.checked
|
||||
};
|
||||
}
|
||||
|
||||
function writeForm(settings) {
|
||||
baseFolderInput.value = settings.baseFolder || "";
|
||||
groupByProfileInput.checked = settings.groupByProfile !== false;
|
||||
saveAsInput.checked = Boolean(settings.saveAs);
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const settings = readForm();
|
||||
const parts = ["Загрузки"];
|
||||
if (settings.baseFolder) parts.push(settings.baseFolder);
|
||||
if (settings.groupByProfile) parts.push("profile");
|
||||
parts.push("video.mp4");
|
||||
pathPreview.textContent = parts.join("/");
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const stored = await chrome.storage.local.get("downloadSettings");
|
||||
writeForm(Object.assign({}, DEFAULT_SETTINGS, stored.downloadSettings || {}));
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const settings = readForm();
|
||||
baseFolderInput.value = settings.baseFolder;
|
||||
await chrome.storage.local.set({ downloadSettings: settings });
|
||||
statusBox.textContent = "Настройки сохранены.";
|
||||
updatePreview();
|
||||
setTimeout(() => {
|
||||
statusBox.textContent = "";
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
baseFolderInput.addEventListener("input", updatePreview);
|
||||
groupByProfileInput.addEventListener("change", updatePreview);
|
||||
saveAsInput.addEventListener("change", updatePreview);
|
||||
saveButton.addEventListener("click", saveSettings);
|
||||
resetButton.addEventListener("click", () => {
|
||||
writeForm(DEFAULT_SETTINGS);
|
||||
statusBox.textContent = "Восстановлены значения по умолчанию — нажмите «Сохранить».";
|
||||
});
|
||||
|
||||
loadSettings();
|
||||
@@ -0,0 +1,706 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0c1017;
|
||||
color: #f4f7fb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 410px;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0, rgba(255, 69, 103, 0.2), transparent 38%),
|
||||
linear-gradient(155deg, #121824 0%, #090c12 72%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding: 20px 20px 14px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 111, 142, 0.72);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(240, 68, 104, 0.34), rgba(191, 37, 76, 0.24));
|
||||
color: #fff;
|
||||
box-shadow: 0 7px 22px rgba(240, 68, 104, 0.18);
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, filter 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
filter: brightness(1.18);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 9px 26px rgba(240, 68, 104, 0.28);
|
||||
}
|
||||
|
||||
.settings-button[aria-expanded="true"] {
|
||||
border-color: #ff7895;
|
||||
background: linear-gradient(135deg, rgba(240, 68, 104, 0.58), rgba(191, 37, 76, 0.42));
|
||||
box-shadow: 0 9px 26px rgba(240, 68, 104, 0.3);
|
||||
}
|
||||
|
||||
.settings-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.settings-button svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 300ms cubic-bezier(.22, 1, .36, 1);
|
||||
}
|
||||
|
||||
.settings-button[aria-expanded="true"] svg {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(240, 68, 104, 0.42);
|
||||
border-radius: 13px;
|
||||
background: rgba(15, 21, 31, 0.96);
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.settings-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.settings-panel-head h2 {
|
||||
margin: 0;
|
||||
color: #f4f7fb;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-panel-head p {
|
||||
margin-top: 3px;
|
||||
color: #748096;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-field-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #c7cfdb;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-text-input {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #344157;
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
background: #0c1119;
|
||||
color: #f4f7fb;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-text-input:focus {
|
||||
border-color: #f04468;
|
||||
box-shadow: 0 0 0 3px rgba(240, 68, 104, 0.13);
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
margin: 7px 0 12px;
|
||||
color: #6f7c91;
|
||||
font-size: 9px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-help code {
|
||||
color: #cbd3df;
|
||||
}
|
||||
|
||||
.settings-check-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid #232d3d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-check-row input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
margin: 1px 0 0;
|
||||
accent-color: #f04468;
|
||||
}
|
||||
|
||||
.settings-check-row strong,
|
||||
.settings-check-row small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-check-row strong {
|
||||
color: #e8edf5;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-check-row small {
|
||||
margin-top: 3px;
|
||||
color: #788499;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-preview {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #2b374a;
|
||||
border-radius: 9px;
|
||||
background: #0c1119;
|
||||
}
|
||||
|
||||
.settings-preview span,
|
||||
.settings-preview strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-preview span {
|
||||
margin-bottom: 4px;
|
||||
color: #748096;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-preview strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: #cdd5e2;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.settings-primary,
|
||||
.settings-secondary {
|
||||
padding: 8px 11px;
|
||||
border-radius: 9px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.settings-primary {
|
||||
background: #f04468;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-primary:hover:not(:disabled) {
|
||||
background: #ff5477;
|
||||
}
|
||||
|
||||
.settings-secondary {
|
||||
background: #202a39;
|
||||
color: #cbd3df;
|
||||
}
|
||||
|
||||
.settings-secondary:hover:not(:disabled) {
|
||||
background: #293548;
|
||||
}
|
||||
|
||||
.settings-status {
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
color: #69dda9;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-status.error {
|
||||
color: #ff7895;
|
||||
}
|
||||
|
||||
.alternative-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 9px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #2c3749;
|
||||
border-radius: 12px;
|
||||
background: rgba(20, 27, 39, 0.88);
|
||||
color: #dce2eb;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alternative-toggle:hover,
|
||||
.alternative-toggle[aria-expanded="true"] {
|
||||
border-color: rgba(240, 68, 104, 0.65);
|
||||
background: #192230;
|
||||
}
|
||||
|
||||
.alternative-icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: rgba(240, 68, 104, 0.16);
|
||||
color: #ff718e;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
transition: transform 300ms cubic-bezier(.22, 1, .36, 1), background 200ms ease, color 200ms ease;
|
||||
}
|
||||
|
||||
.alternative-toggle[aria-expanded="true"] .alternative-icon {
|
||||
transform: rotate(180deg);
|
||||
background: rgba(240, 68, 104, 0.26);
|
||||
color: #ff9bb0;
|
||||
}
|
||||
|
||||
.alternative-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.alternative-chevron {
|
||||
color: #7f8a9e;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
transition: transform 300ms cubic-bezier(.22, 1, .36, 1), color 200ms ease;
|
||||
}
|
||||
|
||||
.alternative-toggle[aria-expanded="true"] .alternative-chevron {
|
||||
transform: rotate(180deg);
|
||||
color: #ff8da4;
|
||||
}
|
||||
|
||||
.diagnostic-tools {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.diagnostic-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px dashed rgba(120, 173, 255, 0.44);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 34, 52, 0.72);
|
||||
color: #bcd4ff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.diagnostic-button:hover:not(:disabled) {
|
||||
border-color: rgba(120, 173, 255, 0.8);
|
||||
background: rgba(31, 49, 75, 0.9);
|
||||
color: #e4eeff;
|
||||
}
|
||||
|
||||
.diagnostic-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.diagnostic-status {
|
||||
margin: 6px 4px 0;
|
||||
color: #6f7c91;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.diagnostic-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagnostic-status.success {
|
||||
color: #69dda9;
|
||||
}
|
||||
|
||||
.diagnostic-status.error {
|
||||
color: #ff7895;
|
||||
}
|
||||
|
||||
.alternative-panel {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #222c3b;
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(135deg, #ff496e, #c92550);
|
||||
box-shadow: 0 10px 30px rgba(255, 73, 110, 0.26);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin-top: 3px;
|
||||
color: #8f9aae;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: #b9c2d0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input[type="url"] {
|
||||
min-width: 0;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid #273142;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: rgba(10, 14, 21, 0.88);
|
||||
color: #f6f8fb;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="url"]:focus {
|
||||
border-color: #e23b61;
|
||||
box-shadow: 0 0 0 3px rgba(226, 59, 97, 0.13);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.primary {
|
||||
padding: 0 15px;
|
||||
border-radius: 10px;
|
||||
background: #f04468;
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: #ff5477;
|
||||
}
|
||||
|
||||
.save-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 10px 0 0;
|
||||
color: #8f9aae;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.save-option input {
|
||||
accent-color: #f04468;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 14px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid #232c3b;
|
||||
border-radius: 10px;
|
||||
background: rgba(18, 24, 35, 0.72);
|
||||
color: #9da8ba;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
border-color: rgba(42, 190, 126, 0.35);
|
||||
color: #69dda9;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
border-color: rgba(255, 73, 110, 0.38);
|
||||
color: #ff7895;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 13px;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #273142;
|
||||
border-radius: 13px;
|
||||
background: rgba(15, 20, 29, 0.92);
|
||||
}
|
||||
|
||||
.result.no-image {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 112px;
|
||||
height: 138px;
|
||||
border-radius: 9px;
|
||||
object-fit: cover;
|
||||
background: #181f2a;
|
||||
}
|
||||
|
||||
.media-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
overflow: hidden;
|
||||
margin: 2px 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collection {
|
||||
margin-top: 14px;
|
||||
padding: 11px;
|
||||
border: 1px solid #273142;
|
||||
border-radius: 13px;
|
||||
background: rgba(15, 20, 29, 0.92);
|
||||
}
|
||||
|
||||
.collection-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.collection-head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collection-head span {
|
||||
color: #7e899c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.collection-items {
|
||||
display: grid;
|
||||
max-height: 320px;
|
||||
gap: 7px;
|
||||
overflow-y: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.collection-item {
|
||||
display: grid;
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 66px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid #303b4e;
|
||||
border-radius: 9px;
|
||||
background: #18202d;
|
||||
color: #e8edf5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collection-item.no-image {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.collection-item:hover {
|
||||
border-color: #f04468;
|
||||
background: #202a39;
|
||||
}
|
||||
|
||||
.collection-thumb {
|
||||
width: 62px;
|
||||
height: 66px;
|
||||
object-fit: cover;
|
||||
background: #222b39;
|
||||
}
|
||||
|
||||
.collection-copy {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.collection-copy strong,
|
||||
.collection-copy small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collection-copy strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.collection-copy small {
|
||||
margin-top: 4px;
|
||||
color: #7f8a9e;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.collection-hint {
|
||||
margin-top: 9px;
|
||||
color: #667287;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.formats {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #344055;
|
||||
border-radius: 9px;
|
||||
background: #1a2230;
|
||||
color: #e8edf5;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.format-button:hover:not(:disabled) {
|
||||
border-color: #f04468;
|
||||
background: #222c3c;
|
||||
}
|
||||
|
||||
.progress-card {
|
||||
margin-top: 12px;
|
||||
padding: 11px;
|
||||
border: 1px solid #273142;
|
||||
border-radius: 11px;
|
||||
background: rgba(15, 20, 29, 0.92);
|
||||
}
|
||||
|
||||
.progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
color: #b9c2d0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 99px;
|
||||
background: #232c3b;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #f04468, #ff8ca3);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 12px;
|
||||
padding: 0 4px;
|
||||
color: #667287;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer strong {
|
||||
color: #aeb8c8;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-button,
|
||||
.settings-button svg,
|
||||
.alternative-icon,
|
||||
.alternative-chevron,
|
||||
.progress-bar {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RedGifs Video Downloader</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<div class="mark">RG</div>
|
||||
<div class="header-copy">
|
||||
<h1>RedGifs Video Downloader</h1>
|
||||
<p>Прямая загрузка без аккаунта и телеметрии</p>
|
||||
</div>
|
||||
<button id="settingsButton" class="settings-button" type="button" title="Настройки" aria-label="Открыть настройки" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section id="settingsPanel" class="settings-panel hidden" aria-label="Настройки сохранения">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>Настройки сохранения</h2>
|
||||
<p>Применяются ко всем новым загрузкам</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="settings-field-label" for="baseFolder">Подпапка внутри «Загрузок»</label>
|
||||
<input id="baseFolder" class="settings-text-input" type="text" maxlength="100" placeholder="RedGifs" autocomplete="off">
|
||||
<p class="settings-help">Можно использовать вложенный путь, например <code>Видео/RedGifs</code>. Корневая папка задаётся в Chrome.</p>
|
||||
|
||||
<label class="settings-check-row">
|
||||
<input id="groupByProfile" type="checkbox">
|
||||
<span>
|
||||
<strong>Группировать по профилям</strong>
|
||||
<small>Создавать подпапку с именем автора ролика.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="settings-check-row">
|
||||
<input id="saveAs" type="checkbox">
|
||||
<span>
|
||||
<strong>Всегда спрашивать место сохранения</strong>
|
||||
<small>Chrome покажет системный диалог для каждого файла.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="settings-preview">
|
||||
<span>Пример пути</span>
|
||||
<strong id="settingsPathPreview">Загрузки/RedGifs/profile/video.mp4</strong>
|
||||
</div>
|
||||
|
||||
<div class="settings-form-actions">
|
||||
<button id="settingsResetButton" class="settings-secondary" type="button">По умолчанию</button>
|
||||
<button id="settingsSaveButton" class="settings-primary" type="button">Сохранить</button>
|
||||
</div>
|
||||
<div id="settingsStatus" class="settings-status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<button id="alternativeToggle" class="alternative-toggle" type="button" aria-expanded="false">
|
||||
<span class="alternative-icon">⇄</span>
|
||||
<span class="alternative-label">Активировать альтернативный способ загрузки</span>
|
||||
<span class="alternative-chevron">⌄</span>
|
||||
</button>
|
||||
|
||||
<section class="diagnostic-tools">
|
||||
<button id="diagnosticButton" class="diagnostic-button" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3h8l1 3h3v14H4V6h3l1-3Z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
<span>Скачать диагностический лог</span>
|
||||
</button>
|
||||
<p id="diagnosticStatus" class="diagnostic-status"></p>
|
||||
</section>
|
||||
|
||||
<section id="alternativePanel" class="alternative-panel hidden">
|
||||
<label class="field-label" for="urlInput">Открытая страница RedGifs</label>
|
||||
<div class="input-row">
|
||||
<input id="urlInput" type="url" placeholder="https://www.redgifs.com/users/..." autocomplete="off">
|
||||
<button id="resolveButton" class="primary" type="button">Сканировать</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status" aria-live="polite">Откройте профиль или ленту RedGifs — расширение найдёт видимые ролики.</div>
|
||||
|
||||
<section id="collection" class="collection hidden">
|
||||
<div class="collection-head">
|
||||
<h2 id="collectionTitle">Видимые ролики</h2>
|
||||
<span id="collectionCount"></span>
|
||||
</div>
|
||||
<div id="collectionItems" class="collection-items"></div>
|
||||
<p class="collection-hint">Нужного ролика нет? Закройте popup, прокрутите профиль ниже и откройте расширение снова.</p>
|
||||
</section>
|
||||
|
||||
<section id="result" class="result hidden">
|
||||
<img id="thumbnail" class="thumbnail hidden" alt="">
|
||||
<div class="media-copy">
|
||||
<h2 id="mediaTitle"></h2>
|
||||
<div id="formats" class="formats"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="progressCard" class="progress-card hidden">
|
||||
<div class="progress-head">
|
||||
<span id="progressLabel">Подготовка…</span>
|
||||
<span id="progressValue">0%</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer id="saveDestination">Определяю путь сохранения…</footer>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,600 @@
|
||||
"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 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 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) {
|
||||
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);
|
||||
renderSettingsPathPreview();
|
||||
}
|
||||
|
||||
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();
|
||||
settingsSaveButton.disabled = true;
|
||||
try {
|
||||
await chrome.storage.local.set({ downloadSettings: settings });
|
||||
downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, settings);
|
||||
writeSettingsForm(downloadSettings);
|
||||
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", () => {
|
||||
writeSettingsForm(DEFAULT_DOWNLOAD_SETTINGS);
|
||||
showSettingsStatus("Значения сброшены — нажмите «Сохранить».");
|
||||
});
|
||||
diagnosticButton.addEventListener("click", async () => {
|
||||
diagnosticButton.disabled = true;
|
||||
diagnosticStatus.className = "diagnostic-status";
|
||||
diagnosticStatus.textContent = "Собираю DOM, события и сетевые ошибки…";
|
||||
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 = false;
|
||||
}
|
||||
});
|
||||
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);
|
||||
try {
|
||||
const stored = await chrome.storage.local.get("downloadSettings");
|
||||
downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, stored.downloadSettings || {});
|
||||
writeSettingsForm(downloadSettings);
|
||||
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" || !changes.downloadSettings) return;
|
||||
downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, changes.downloadSettings.newValue || {});
|
||||
writeSettingsForm(downloadSettings);
|
||||
renderSaveDestination();
|
||||
});
|
||||
Reference in new issue
Block a user