455 lines
31 KiB
JavaScript
455 lines
31 KiB
JavaScript
import { bytes, canonicalUrl, codecName, duration, progressText, downloadSizeText, processingText, preferenceOptions, errorText, updateText, checkHelperCompatibility } from "./shared.js";
|
||
import { SESSION_PERMISSION } from "./session.js";
|
||
import { createOnboarding } from "./onboarding.js";
|
||
import { createUiConnection } from "./ui-connection.js";
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const query = new URLSearchParams(location.search);
|
||
const embedded = query.get("embedded") === "1";
|
||
if (embedded) document.documentElement.dataset.embedded = "true";
|
||
let url = canonicalUrl(query.get("url"));
|
||
let metadata = null;
|
||
let hardware = null;
|
||
let preference = {};
|
||
let onboarding = null;
|
||
let theme = "auto";
|
||
let pageTheme = query.get("theme") || "auto";
|
||
let jobStates = {};
|
||
let helper = { status: "checking", message: "Проверяю связь с локальным помощником." };
|
||
let helperBusy = false;
|
||
let busy = false;
|
||
let processingUnavailable = false;
|
||
let sessionBusy = false;
|
||
let session = { status: "checking" };
|
||
const sourceTabId = Number(query.get("tabId")) || null;
|
||
let previousVideoContainer = "mp4";
|
||
let previousAudioContainer = "m4a";
|
||
let previousMode = "video";
|
||
const { call } = createUiConnection({ onEvent: message => {
|
||
if (message.event === "helper") { renderHelper(message.helper); return; }
|
||
if (message.event === "remove_job") { delete jobStates[message.jobId]; renderJobs(); return; }
|
||
if (message.event === "job") { jobStates[message.job.id] = message.job; renderJobs(); return; }
|
||
if (message.event === "state") { jobStates = Object.fromEntries(message.jobs.map(job => [job.id, job])); renderJobs(); if (message.helper) renderHelper(message.helper); return; }
|
||
}, onDisconnect: () => {
|
||
renderHelper({ status: "offline", message: "Связь с расширением прервана. Нажмите «Проверить подключение» или «Запустить» — соединение восстановится автоматически." });
|
||
} });
|
||
|
||
function showError(message) {
|
||
$("alert").textContent = errorText(message || ""); $("alert").hidden = !message;
|
||
$("error-actions").hidden = !message || !url || helper.status !== "running" || hardware?.ready === false;
|
||
}
|
||
function renderSession(state = session) {
|
||
session = state;
|
||
$("session-panel").dataset.state = state.status;
|
||
$("session-status").textContent = { checking: "Проверяем вход в YouTube…", connected: "Вход браузера подключён", signed_out: "Вход в YouTube не найден",
|
||
permission_required: "Нужно разрешение на вход", disabled: "Вход в YouTube не подключён", error: "Не удалось проверить вход" }[state.status];
|
||
$("session-message").textContent = state.message || "Используется вход из этого профиля браузера.";
|
||
$("session-enable").textContent = ["connected", "signed_out"].includes(state.status) ? "Проверить вход" : "Использовать вход в YouTube";
|
||
$("session-enable").disabled = sessionBusy || busy;
|
||
$("session-disable").hidden = !["connected", "signed_out", "permission_required"].includes(state.status);
|
||
$("session-disable").disabled = sessionBusy || busy;
|
||
}
|
||
async function refreshSession() {
|
||
try { renderSession(await call("session_status", { tabId: sourceTabId })); }
|
||
catch (error) { renderSession({ status: "error", message: error.message }); }
|
||
}
|
||
$("session-enable").addEventListener("click", async () => {
|
||
if (sessionBusy) return;
|
||
sessionBusy = true; renderSession(); showError("");
|
||
try {
|
||
// Request from the click handler to preserve the browser's user gesture.
|
||
if (!["connected", "signed_out"].includes(session.status)) {
|
||
if (!await chrome.permissions.request(SESSION_PERMISSION)) throw new Error("Доступ к входу YouTube не разрешён. Вы можете разрешить его этой кнопкой позднее.");
|
||
renderSession(await call("session_enable", { enabled: true, tabId: sourceTabId }));
|
||
} else await refreshSession();
|
||
if (session.status === "connected" && url && helper.status === "running") await loadVideo();
|
||
} catch (error) { showError(error.message); }
|
||
finally { sessionBusy = false; renderSession(); }
|
||
});
|
||
$("session-disable").addEventListener("click", async () => {
|
||
sessionBusy = true; renderSession();
|
||
try { renderSession(await call("session_enable", { enabled: false, tabId: sourceTabId })); }
|
||
catch (error) { showError(error.message); }
|
||
finally { sessionBusy = false; renderSession(); }
|
||
});
|
||
$("retry-video").addEventListener("click", loadVideo);
|
||
function renderDependencies() {
|
||
const labels = { python: "Python", yt_dlp: "yt-dlp", ejs: "YouTube JS (EJS)", node: "Node.js", ffmpeg: "FFmpeg + FFprobe" };
|
||
const measured = helper.status === "running" ? hardware?.dependencies || [] : [];
|
||
$("dependencies-status").textContent = measured.length ? `${measured.filter(item => item.status === "installed").length}/${measured.length} готовы` : "Ещё не проверены";
|
||
for (const [id, label] of Object.entries(labels)) {
|
||
let row = $("dependencies").querySelector(`[data-dependency="${id}"]`);
|
||
if (!row) {
|
||
row = document.createElement("div"); row.className = "dependency-row"; row.dataset.dependency = id;
|
||
row.innerHTML = '<span class="dependency-dot"></span><div class="dependency-copy"><span class="dependency-label"></span><small class="dependency-version"></small></div>';
|
||
row.querySelector(".dependency-label").textContent = label;
|
||
$("dependencies").append(row);
|
||
}
|
||
const item = measured.find(item => item.id === id);
|
||
row.dataset.status = item?.status || "unknown";
|
||
row.querySelector(".dependency-version").textContent = item?.status === "installed" ? item.version || "Установлен" : item?.status === "outdated" ? `${item.version} · нужно обновить` : item ? "Не установлен" : "Проверка после запуска помощника";
|
||
}
|
||
}
|
||
function renderHelper(state = helper) {
|
||
if (state.status === "running") {
|
||
const compatibility = checkHelperCompatibility(state.hardware, chrome.runtime.getManifest().version);
|
||
state = { ...state, compatibility, ...(compatibility.compatible ? {} : { status: "incompatible", message: compatibility.message }) };
|
||
}
|
||
const componentsBecameReady = hardware?.ready === false && state.hardware?.ready === true;
|
||
helper = state;
|
||
const connected = state.status === "running";
|
||
const runningJobs = Object.values(jobStates).some(job => job.status === "running");
|
||
const updating = Object.values(jobStates).some(job => job.kind === "update" && job.status === "running");
|
||
$("helper-panel").dataset.state = state.status;
|
||
$("helper-status").textContent = { running: "Помощник работает", checking: "Проверяем помощник…", missing: "Помощник не установлен",
|
||
offline: "Помощник отключён", error: "Помощник не отвечает", incompatible: "Нужно обновить версии", idle: "Помощник не подключён" }[state.status] || "Помощник не подключён";
|
||
$("helper-message").textContent = connected && state.hardware?.bundle?.mode === "autonomous"
|
||
? "Автономный комплект: компоненты установлены вместе и могут обновляться из интернета." : state.message || "";
|
||
const helperVersion = state.hardware?.version || state.hardware?.bundle?.version;
|
||
updateText($("helper-versions"), `Расширение ${chrome.runtime.getManifest().version} · Помощник ${helperVersion || "не проверен"}${connected ? " · Версии совместимы" : ""}`);
|
||
$("helper-versions").dataset.state = state.status;
|
||
$("helper-start").textContent = connected || state.status === "incompatible" ? "Проверить" : "Запустить";
|
||
$("helper-start").disabled = helperBusy || state.status === "checking";
|
||
$("helper-install").textContent = state.compatibility?.update === "extension" ? "Обновить расширение" : state.compatibility?.update === "helper" ? "Обновить помощник" : connected ? state.hardware?.ready === false ? "Восстановить" : "Переустановить" : "Скачать помощник";
|
||
$("helper-install").title = state.compatibility?.update === "extension" ? "Откройте релизы, скачайте новое расширение, замените файлы и нажмите «Обновить» на странице расширений браузера." : "Открыть релизы проекта. Скачайте отдельный YouTubeDL-Helper-Setup.exe и запустите его вручную.";
|
||
const supportsUpdates = connected && state.hardware?.supportsComponentUpdates === true;
|
||
$("helper-update").hidden = !supportsUpdates;
|
||
$("helper-update").disabled = helperBusy || busy || runningJobs || state.hardware?.ready === false;
|
||
$("auto-update-row").hidden = $("update-note").hidden = !supportsUpdates;
|
||
$("auto-update").disabled = helperBusy || updating;
|
||
if (supportsUpdates) $("auto-update").checked = state.hardware.updateSettings?.enabled === true;
|
||
$("helper-install").setAttribute("aria-disabled", String(helperBusy || runningJobs));
|
||
$("helper-remove").disabled = !connected || helperBusy || runningJobs;
|
||
$("helper-remove").title = runningJobs ? "Дождитесь завершения загрузок" : "Отключить и удалить регистрацию помощника";
|
||
if (!connected) {
|
||
hardware = null;
|
||
$("download-form").hidden = true;
|
||
$("hardware").textContent = "Видеокарта ещё не проверена";
|
||
if (!["idle", "checking"].includes(state.status)) $("helper-panel").open = true;
|
||
} else {
|
||
hardware = state.hardware || hardware;
|
||
const accelerators = [["nvenc", "NVIDIA NVENC"], ["amf", "AMD AMF"]];
|
||
const checked = accelerators.filter(([key]) => hardware?.[key]?.length).map(([key, name]) => `${name}: ${hardware[key].map(codecName).join(", ")}`);
|
||
$("hardware").textContent = hardware?.gpu || "Видеокарта не определена";
|
||
$("hardware").title = checked.length ? `Проверено кодирование: ${checked.join("; ")}` : "Аппаратное кодирование недоступно. Конвертация выполняется на CPU.";
|
||
for (const [key] of accelerators) {
|
||
const option = $("engine").querySelector(`[value="${key}"]`);
|
||
option.disabled = !hardware?.[key]?.length;
|
||
if (option.disabled && $("engine").value === key) $("engine").value = "auto";
|
||
}
|
||
$("download-form").hidden = busy || !metadata || hardware?.ready === false;
|
||
}
|
||
$("download").disabled = !connected || helperBusy || busy || hardware?.ready === false || processingUnavailable
|
||
|| Object.values(jobStates).some(job => ["dependency", "update"].includes(job.kind) && job.status === "running");
|
||
renderDependencies();
|
||
onboarding?.render();
|
||
if (metadata) updateHint();
|
||
if (componentsBecameReady && url && !metadata && !busy && !helperBusy && !onboarding?.isOpen) queueMicrotask(loadVideo);
|
||
}
|
||
function loading(active, message = "Получаю доступное качество…") {
|
||
busy = active;
|
||
$("loading").hidden = !active;
|
||
$("loading-text").textContent = message;
|
||
$("download-form").hidden = active || !metadata || helper.status !== "running" || hardware?.ready === false;
|
||
renderSession();
|
||
}
|
||
function applyTheme() {
|
||
const resolved = theme === "auto" ? pageTheme : theme;
|
||
if (["light", "dark"].includes(resolved)) document.documentElement.dataset.theme = resolved;
|
||
else delete document.documentElement.dataset.theme;
|
||
$("theme").title = `Тема: ${{ auto: "автоматически", light: "светлая", dark: "тёмная" }[theme]}`;
|
||
$("theme").textContent = { auto: "◐", light: "☀", dark: "☾" }[theme];
|
||
}
|
||
window.addEventListener("message", event => {
|
||
if (embedded && event.source === parent && /^https:\/\/(www|m)\.youtube\.com$/.test(event.origin) &&
|
||
event.data?.type === "YTDL_THEME" && ["light", "dark"].includes(event.data.theme)) {
|
||
pageTheme = event.data.theme; applyTheme();
|
||
}
|
||
});
|
||
$("theme").addEventListener("click", () => {
|
||
theme = { auto: "light", light: "dark", dark: "auto" }[theme];
|
||
chrome.storage.local.set({ theme }); applyTheme();
|
||
});
|
||
$("close").addEventListener("click", () => {
|
||
if (embedded) chrome.runtime.sendMessage({ type: "CLOSE_MENU" }).catch(error => showError(error.message));
|
||
else window.close();
|
||
});
|
||
$("open-history").addEventListener("click", () => {
|
||
call("open_history", { theme: document.documentElement.dataset.theme || "auto" }).catch(error => showError(error.message));
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (event.key === "Escape" && !event.defaultPrevented && !onboarding?.isOpen) $("close").click();
|
||
});
|
||
function mode() { return document.querySelector('input[name="mode"]:checked').value; }
|
||
function options(select, items) {
|
||
select.replaceChildren(...items.map(([value, text]) => {
|
||
const option = document.createElement("option"); option.value = value; option.textContent = text; return option;
|
||
}));
|
||
}
|
||
function populate() {
|
||
options($("quality"), [["best", "Лучшее доступное качество"], ...metadata.videos.map(video => [video.id,
|
||
`${video.height}p${video.fps > 30 ? ` ${Math.round(video.fps)} fps` : ""} · ${codecName(video.codec)}${video.hdr && video.hdr !== "SDR" ? ` · ${video.hdr}` : ""}${video.hasAudio ? " · со звуком" : ""}${video.size ? ` · ≈${bytes(video.size)}` : ""}`])]);
|
||
options($("audio-quality"), [["best", "Лучшая доступная дорожка"], ...metadata.audios.map(audio => [audio.id,
|
||
`${codecName(audio.codec)}${audio.bitrate ? ` · ${Math.round(audio.bitrate)} кбит/с` : ""}${audio.language ? ` · ${audio.language}` : ""}${audio.note ? ` · ${audio.note}` : ""}`])]);
|
||
$("filename").placeholder = metadata.title;
|
||
const restored = preferenceOptions(metadata, preference);
|
||
$("quality").value = restored.videoId; $("audio-quality").value = restored.audioId;
|
||
updateMode();
|
||
}
|
||
function updateMode() {
|
||
const current = mode();
|
||
if (previousMode === "audio") previousAudioContainer = $("container").value || previousAudioContainer;
|
||
else previousVideoContainer = $("container").value || previousVideoContainer;
|
||
previousMode = current;
|
||
$("video-options").hidden = current === "audio";
|
||
$("audio-options").hidden = current === "video_only";
|
||
$("codec-field").hidden = current === "audio";
|
||
$("bitrate-field").hidden = current !== "audio";
|
||
if (current === "audio") {
|
||
options($("container"), [["m4a", "M4A · AAC"], ["mp3", "MP3"], ["opus", "Opus"], ["flac", "FLAC"], ["wav", "WAV"]]);
|
||
$("container").value = previousAudioContainer;
|
||
} else {
|
||
options($("container"), [["mp4", "MP4"], ["mkv", "MKV"], ["webm", "WebM"]]);
|
||
$("container").value = previousVideoContainer;
|
||
}
|
||
updateHint();
|
||
}
|
||
function updateHint() {
|
||
if (!metadata) return;
|
||
const current = mode();
|
||
const video = $("quality").value === "best" ? metadata.videos[0] : metadata.videos.find(item => item.id === $("quality").value);
|
||
const audio = $("audio-quality").value === "best" ? metadata.audios[0] : metadata.audios.find(item => item.id === $("audio-quality").value);
|
||
$("audio-quality").disabled = current === "video" && Boolean(video?.hasAudio);
|
||
$("audio-note").textContent = current === "audio" ? "Сохраняется только звук." : video?.hasAudio
|
||
? "В этой дорожке звук уже есть — он будет сохранён." : "Отдельный звук скачается и автоматически добавится к видео.";
|
||
const container = $("container").value;
|
||
const codec = $("codec").value;
|
||
const isCompatible = container === "mkv" || (container === "mp4" && ["H.264", "HEVC", "AV1"].includes(codecName(video?.codec)))
|
||
|| (container === "webm" && ["VP9", "AV1", "VP8"].includes(codecName(video?.codec)));
|
||
const outputCodec = codec === "auto" ? container === "webm" ? "av1" : video?.hdr && video.hdr !== "SDR" ? "hevc" : "h264" : codec;
|
||
const selectedEngine = $("engine").value;
|
||
const gpuEngine = ["nvenc", "amf"].find(key => ["auto", key].includes(selectedEngine) && hardware?.[key]?.includes(outputCodec)
|
||
&& !(key === "amf" && video?.hdr && video.hdr !== "SDR" && !hardware?.amf10bit?.includes(outputCodec)));
|
||
const copiesVideo = codec === "copy" || (codec === "auto" && isCompatible);
|
||
processingUnavailable = current !== "audio" && !copiesVideo && ["nvenc", "amf"].includes(selectedEngine) && !gpuEngine;
|
||
$("processing-hint").textContent = current === "audio"
|
||
? ["flac", "wav"].includes(container) ? "Без дополнительного сжатия. Исходное качество YouTube не повышается." : "Совместимый звук сохраняется без повторного кодирования."
|
||
: copiesVideo
|
||
? "Видео сохранится без перекодирования — быстро и без потери качества." : processingUnavailable
|
||
? `Выбранный кодек недоступен в ${selectedEngine === "amf" ? "AMD AMF" : "NVIDIA NVENC"}. Выберите «Авто» или другой кодек.` : gpuEngine
|
||
? `При конвертации используется ${gpuEngine === "amf" ? "AMD AMF" : "NVIDIA NVENC"}. Разрешение сохраняется.` : "Конвертация на CPU. Разрешение сохраняется.";
|
||
const estimate = current === "audio" ? audio?.size : (video?.size || 0) + (current === "video" && !video?.hasAudio ? audio?.size || 0 : 0);
|
||
$("estimate").textContent = estimate ? `≈ ${bytes(estimate)}` : "";
|
||
$("bitrate").disabled = ["flac", "wav"].includes(container);
|
||
$("download").disabled = helper.status !== "running" || helperBusy || busy || hardware?.ready === false || processingUnavailable
|
||
|| Object.values(jobStates).some(job => ["dependency", "update"].includes(job.kind) && job.status === "running");
|
||
}
|
||
async function loadVideo() {
|
||
if (!url || !hardware || busy || onboarding?.isOpen) return;
|
||
if (hardware.ready === false) { $("helper-panel").open = true; loading(false); return; }
|
||
showError(""); loading(true);
|
||
metadata = null;
|
||
$("video-title").textContent = "Получаю качество ролика…";
|
||
$("video-meta").textContent = "Помощник подключён. Запрашиваю доступные дорожки YouTube.";
|
||
try {
|
||
metadata = await call("inspect", { url, tabId: sourceTabId, refresh: true });
|
||
$("video-title").textContent = metadata.title;
|
||
$("video-meta").textContent = [metadata.channel, duration(metadata.duration)].filter(Boolean).join(" · ");
|
||
populate();
|
||
} catch (error) {
|
||
$("video-title").textContent = helper.status === "running" ? "Не удалось получить качество" : "Подключим локальный помощник";
|
||
$("video-meta").textContent = helper.status === "running" ? "Помощник подключён. YouTube не вернул дорожки для этого ролика." : "Проверьте состояние помощника выше.";
|
||
showError(error.message);
|
||
}
|
||
finally { loading(false); }
|
||
}
|
||
$("refresh").addEventListener("click", loadVideo);
|
||
$("load-url").addEventListener("click", () => {
|
||
const candidate = canonicalUrl($("url-input").value);
|
||
if (!candidate) { showError("Введите ссылку на ролик или Shorts с YouTube."); return; }
|
||
url = candidate; loadVideo();
|
||
});
|
||
$("pick-folder").addEventListener("click", async () => {
|
||
$("pick-folder").disabled = true;
|
||
try {
|
||
const result = await call("pick_folder", { current: $("folder").value });
|
||
if (result.folder) { $("folder").value = result.folder; savePreferences(); }
|
||
} catch (error) { showError(error.message); }
|
||
finally { $("pick-folder").disabled = false; }
|
||
});
|
||
$("pick-work-folder").addEventListener("click", async () => {
|
||
$("pick-work-folder").disabled = true;
|
||
try {
|
||
const result = await call("pick_folder", { current: $("work-folder").value || $("folder").value });
|
||
if (result.folder) { $("work-folder").value = result.folder; await savePreferences(); }
|
||
} catch (error) { showError(error.message); }
|
||
finally { $("pick-work-folder").disabled = false; }
|
||
});
|
||
function savePreferences() {
|
||
preference = { folder: $("folder").value, workFolder: $("work-folder").value.trim(), engine: $("engine").value, preset: $("preset").value,
|
||
videoContainer: previousVideoContainer, audioContainer: previousAudioContainer, bitrate: Number($("bitrate").value),
|
||
mode: mode(), codec: $("codec").value, videoId: $("quality").value || "best", audioId: $("audio-quality").value || "best", filename: $("filename").value.trim(),
|
||
quality: $("quality").value === "best" ? null : (() => { const video = metadata?.videos.find(video => video.id === $("quality").value); return video ? { height: video.height, fps: video.fps, codec: codecName(video.codec) } : null; })(),
|
||
audioLanguage: metadata?.audios.find(audio => audio.id === $("audio-quality").value)?.language || "" };
|
||
return chrome.storage.local.set({ preferences: preference });
|
||
}
|
||
document.querySelectorAll('input[name="mode"]').forEach(input => input.addEventListener("change", () => { updateMode(); savePreferences(); }));
|
||
for (const id of ["quality", "audio-quality", "codec", "container", "engine", "bitrate", "preset"]) $(id).addEventListener("change", () => {
|
||
if (id === "container") { if (mode() === "audio") previousAudioContainer = $(id).value; else previousVideoContainer = $(id).value; }
|
||
updateHint(); savePreferences();
|
||
});
|
||
$("folder").addEventListener("change", savePreferences);
|
||
$("work-folder").addEventListener("change", savePreferences);
|
||
$("filename").addEventListener("change", savePreferences);
|
||
$("save-settings").addEventListener("click", async () => {
|
||
await savePreferences();
|
||
if (embedded) await chrome.runtime.sendMessage({ type: "CLOSE_MENU" });
|
||
else window.close();
|
||
});
|
||
$("download-form").addEventListener("submit", async event => {
|
||
event.preventDefault(); showError("");
|
||
if (!metadata) return;
|
||
if (!$("folder").value.trim()) { showError("Сначала выберите папку сохранения."); return; }
|
||
$("download").disabled = true;
|
||
try {
|
||
await call("download", { url, title: metadata.title, tabId: Number(query.get("tabId")) || null,
|
||
mode: mode(), videoId: $("quality").value, audioId: $("audio-quality").value,
|
||
container: $("container").value, codec: $("codec").value, engine: $("engine").value,
|
||
preset: $("preset").value, bitrate: Number($("bitrate").value), folder: $("folder").value.trim(), workFolder: $("work-folder").value.trim(), filename: $("filename").value.trim() });
|
||
savePreferences();
|
||
if (embedded) await chrome.runtime.sendMessage({ type: "CLOSE_MENU" });
|
||
} catch (error) { showError(error.message); }
|
||
finally { $("download").disabled = false; }
|
||
});
|
||
|
||
function renderJobs() {
|
||
renderHelper();
|
||
const sorted = Object.values(jobStates).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||
const active = sorted.filter(job => job.status === "running");
|
||
const updateJob = sorted.find(job => job.kind === "update");
|
||
const orderedCards = { "active-jobs": [] };
|
||
$("update-note").textContent = updateJob?.status === "complete" ? updateJob.result?.title || "Обновление завершено."
|
||
: updateJob?.status === "error" ? updateJob.error : "Проверка раз в сутки, пока помощник работает. Обновления устанавливаются между загрузками.";
|
||
$("active-jobs-section").hidden = !active.length;
|
||
for (const job of active) {
|
||
let card = document.querySelector(`[data-job-id="${CSS.escape(job.id)}"]`);
|
||
if (!card) {
|
||
card = document.createElement("article"); card.className = "job-card"; card.dataset.jobId = job.id;
|
||
card.innerHTML = '<div class="job-top"><div><div class="job-title"></div><div class="job-stage"></div></div><span class="job-percent"></span></div><div class="progress-track"><div class="progress-fill"></div></div><div class="job-transfer" hidden></div><div class="job-error" hidden></div><div class="job-actions"></div>';
|
||
}
|
||
orderedCards["active-jobs"].push(card);
|
||
if (card.dataset.status !== job.status) card.dataset.status = job.status;
|
||
updateText(card.querySelector(".job-title"), job.title || "Видео");
|
||
const extra = job.stage === "downloading" && Number.isFinite(job.speed) && job.speed >= 0 ? ` · ${job.speed === 0 ? "0 МБ" : bytes(job.speed)}/с` : ["merging", "remuxing", "encoding", "audio"].includes(job.stage) && job.speed ? ` · ${job.speed}` : "";
|
||
updateText(card.querySelector(".job-stage"), progressText(job) + extra);
|
||
updateText(card.querySelector(".job-percent"), job.status === "complete" ? "✓" : Number.isFinite(job.percent) ? `${Math.round(job.percent)}%` : "");
|
||
const fill = card.querySelector(".progress-fill");
|
||
fill.classList.toggle("indeterminate", job.status === "running" && !Number.isFinite(job.percent));
|
||
const width = `${Number.isFinite(job.percent) ? Math.min(100, Math.max(0, job.percent)) : 0}%`;
|
||
if (fill.style.width !== width) fill.style.width = width;
|
||
const transfer = card.querySelector(".job-transfer");
|
||
let transferText = downloadSizeText(job) || processingText(job);
|
||
if (["merging", "remuxing", "encoding", "audio"].includes(job.stage) && Number.isFinite(job.eta) && job.eta > 0) transferText += ` · осталось ≈ ${Math.ceil(job.eta)} с`;
|
||
updateText(transfer, transferText);
|
||
transfer.hidden = !transfer.textContent;
|
||
const error = card.querySelector(".job-error"); error.hidden = !job.error; updateText(error, errorText(job.error || ""));
|
||
const actions = card.querySelector(".job-actions");
|
||
if (actions.dataset.status !== job.status) {
|
||
actions.dataset.status = job.status; actions.replaceChildren();
|
||
if (["running", "complete"].includes(job.status) && job.canCancel !== false && !(["dependency", "update"].includes(job.kind) && job.status === "complete")) {
|
||
const button = document.createElement("button"); button.type = "button";
|
||
button.textContent = job.status === "complete" ? "Показать файл ↗" : "Отменить";
|
||
button.addEventListener("click", () => call(job.status === "complete" ? "reveal" : "cancel", { jobId: job.id }).catch(error => showError(error.message)));
|
||
actions.append(button);
|
||
}
|
||
}
|
||
}
|
||
for (const [id, cards] of Object.entries(orderedCards)) {
|
||
const parent = $(id);
|
||
for (const card of [...parent.children]) if (!active.some(job => job.id === card.dataset.jobId)) card.remove();
|
||
cards.forEach((card, index) => {
|
||
if (parent.children[index] !== card) parent.insertBefore(card, parent.children[index] || null);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function connectHelper(action = "hello") {
|
||
if (helperBusy) return;
|
||
helperBusy = true; showError("");
|
||
renderHelper({ status: "checking", message: "Запускаю и проверяю локальный помощник…" });
|
||
try {
|
||
hardware = await call(action);
|
||
renderHelper({ status: "running", message: "Помощник отвечает. Загрузка и обработка доступны.", hardware });
|
||
$("folder").value ||= preference.folder || hardware.defaultFolder;
|
||
loading(false);
|
||
if (url && !metadata && hardware.ready !== false && !onboarding?.isOpen) await loadVideo();
|
||
else if (hardware.ready === false) { $("helper-panel").open = true; $("video-title").textContent = "Установим недостающие компоненты"; }
|
||
else if (!url) $("video-title").textContent = "Какой ролик скачать?";
|
||
} catch (error) {
|
||
if (helper.status === "checking") renderHelper({ status: "error", message: error.message });
|
||
loading(false); if (!metadata) $("video-title").textContent = "Подключим локальный помощник";
|
||
} finally { helperBusy = false; renderHelper(); }
|
||
}
|
||
$("helper-start").addEventListener("click", () => connectHelper("helper_start"));
|
||
$("helper-update").addEventListener("click", async () => {
|
||
helperBusy = true; renderHelper(); showError("");
|
||
try { await call("helper_update", { tabId: sourceTabId, title: "Обновление компонентов" }); }
|
||
catch (error) { showError(error.message); }
|
||
finally { helperBusy = false; renderHelper(); }
|
||
});
|
||
$("auto-update").addEventListener("change", async () => {
|
||
const enabled = $("auto-update").checked;
|
||
try {
|
||
const settings = await call("update_settings", { enabled });
|
||
if (hardware) hardware.updateSettings = settings;
|
||
renderHelper();
|
||
} catch (error) { renderHelper(); showError(error.message); }
|
||
});
|
||
$("helper-install").addEventListener("click", () => {
|
||
$("helper-install-note").hidden = false;
|
||
});
|
||
$("helper-remove").addEventListener("click", async () => {
|
||
helperBusy = true; renderHelper(); showError("");
|
||
try {
|
||
await call("helper_remove");
|
||
renderHelper({ status: "missing", message: "Помощник удалён. Для установки скачайте и запустите установщик." });
|
||
} catch (error) { showError(error.message); }
|
||
finally { helperBusy = false; renderHelper(); }
|
||
});
|
||
const helperTimer = setInterval(async () => {
|
||
if (!helperBusy && helper.status !== "checking") {
|
||
try { renderHelper(await call("helper_status")); } catch {}
|
||
}
|
||
}, 10000);
|
||
window.addEventListener("pagehide", () => clearInterval(helperTimer), { once: true });
|
||
|
||
function restorePreferences(saved) {
|
||
preference = saved;
|
||
$("folder").value = preference.folder || hardware?.defaultFolder || "";
|
||
$("work-folder").value = preference.workFolder || "";
|
||
$("engine").value = preference.engine || "auto"; $("preset").value = preference.preset || "balanced";
|
||
$("codec").value = preference.codec || "auto"; $("filename").value = preference.filename || "";
|
||
const preferredMode = document.querySelector(`input[name="mode"][value="${["video", "video_only", "audio"].includes(preference.mode) ? preference.mode : "video"}"]`);
|
||
preferredMode.checked = true; previousMode = mode();
|
||
$("bitrate").value = String(preference.bitrate || 256);
|
||
previousVideoContainer = preference.videoContainer || "mp4"; previousAudioContainer = preference.audioContainer || "m4a";
|
||
options($("container"), mode() === "audio" ? [["m4a", "M4A · AAC"], ["mp3", "MP3"], ["opus", "Opus"], ["flac", "FLAC"], ["wav", "WAV"]]
|
||
: [["mp4", "MP4"], ["mkv", "MKV"], ["webm", "WebM"]]);
|
||
$("container").value = mode() === "audio" ? previousAudioContainer : previousVideoContainer;
|
||
if (metadata) populate(); else updateMode();
|
||
}
|
||
async function init() {
|
||
const saved = await chrome.storage.local.get(["theme", "preferences", "onboarding"]);
|
||
theme = saved.theme || "auto"; applyTheme(); restorePreferences(saved.preferences || {});
|
||
$("manual-url").hidden = Boolean(url);
|
||
onboarding = createOnboarding({ call, connectHelper: () => connectHelper("helper_start"), getHelper: () => helper,
|
||
applyPreferences: restorePreferences, onClose: () => { if (url && !metadata && hardware?.ready) loadVideo(); } });
|
||
$("open-setup").addEventListener("click", () => onboarding.open().catch(error => showError(error.message)));
|
||
// Existing configured profiles keep their preferences and can open the guide with ?.
|
||
if ((!saved.onboarding && !saved.preferences?.folder) || query.get("setup") === "1") await onboarding.open();
|
||
await refreshSession();
|
||
await connectHelper();
|
||
if (query.get("notice")) showError(query.get("notice"));
|
||
}
|
||
init();
|