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