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