Files
DepressedCat 5f2aad00f4
Check extension and helper / check (push) Canceled after 0s
Publish YouTubeDL 1.4.5 and website on Gitea
2026-10-10 23:53:17 +03:00

455 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();