import { bytes, canonicalUrl, errorText, updateText } from "./shared.js"; import { createUiConnection } from "./ui-connection.js"; const $ = id => document.getElementById(id); const tabs = [$("downloads-tab"), $("errors-tab")]; const dateFormat = new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }); let jobs = {}; let theme = "auto"; let call; let refreshing = false; let reconnectTimer; let reconnectDelay = 1000; function showError(message) { updateText($("history-alert"), errorText(message || "")); $("history-alert").hidden = !message; } function applyTheme() { if (["dark", "light"].includes(theme)) document.documentElement.dataset.theme = theme; else delete document.documentElement.dataset.theme; const label = { auto: "автоматически", light: "светлая", dark: "тёмная" }[theme]; $("theme").title = `Тема: ${label}`; $("theme").setAttribute("aria-label", `Сменить тему. Сейчас: ${label}`); updateText($("theme"), { auto: "◐", light: "☀", dark: "☾" }[theme]); } $("theme").addEventListener("click", () => { theme = { auto: "light", light: "dark", dark: "auto" }[theme]; applyTheme(); chrome.storage.local.set({ theme }).catch(error => showError(error.message)); }); function selectTab(index) { tabs.forEach((tab, position) => { const selected = position === index; tab.setAttribute("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1; $(tab.getAttribute("aria-controls")).hidden = !selected; }); } tabs.forEach((tab, index) => { tab.addEventListener("click", () => selectTab(index)); tab.addEventListener("keydown", event => { const next = { ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[event.key]; if (next === undefined) return; event.preventDefault(); selectTab(next); tabs[next].focus(); }); }); function record(job) { const card = document.createElement("article"); card.className = "history-entry"; card.dataset.jobId = job.id; card.dataset.status = job.status; card.innerHTML = '

'; updateText(card.querySelector(".entry-title"), job.result?.title || job.title || "Скачивание видео"); updateText(card.querySelector(".entry-status"), { complete: "Сохранено", cancelled: "Отменено", error: "Ошибка" }[job.status]); const meta = card.querySelector(".entry-meta"); if (Number.isFinite(job.createdAt) && job.createdAt > 0) { const date = new Date(job.createdAt * 1000); if (Number.isFinite(date.getTime())) { const time = document.createElement("time"); time.dateTime = date.toISOString(); time.textContent = dateFormat.format(date); meta.append(time); } } for (const text of [({ update: "Обновление компонентов", dependency: "Установка компонента", inspection: "Получение качества" })[job.kind], job.result?.filename, bytes(job.result?.size)]) { if (!text) continue; const span = document.createElement("span"); span.textContent = text; meta.append(span); } const path = card.querySelector(".entry-path"); if (job.result?.path) { path.textContent = job.result.path; path.hidden = false; } const error = card.querySelector(".entry-error"); if (job.status === "error") { error.textContent = errorText(job.error || "Операция завершилась ошибкой."); error.hidden = false; } const actions = card.querySelector(".entry-actions"); if (job.status === "complete" && job.result?.path) { const reveal = document.createElement("button"); reveal.type = "button"; reveal.className = "small-button"; reveal.textContent = "Показать файл"; reveal.addEventListener("click", async () => { reveal.disabled = true; showError(""); try { await call("reveal", { jobId: job.id }); } catch (error) { showError(error.message); } finally { reveal.disabled = false; } }); actions.append(reveal); } const url = canonicalUrl(job.url); if (url) { const link = document.createElement("a"); link.href = url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = "Открыть на YouTube ↗"; actions.append(link); } actions.hidden = !actions.children.length; return card; } function render() { const recent = Object.values(jobs).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 30); const groups = { downloads: recent.filter(job => ["complete", "cancelled"].includes(job.status) && (!job.kind || job.kind === "download")), errors: recent.filter(job => job.status === "error"), }; for (const [name, entries] of Object.entries(groups)) { updateText($(`${name}-count`), String(entries.length)); $(`${name}-empty`).hidden = Boolean(entries.length); const list = $(`${name}-list`); // Terminal records do not have progress animations. Retain cards on unrelated events. const cards = entries.map(job => { const existing = [...list.children].find(card => card.dataset.jobId === job.id); const signature = JSON.stringify(job); if (existing?.dataset.signature === signature) return existing; const card = record(job); card.dataset.signature = signature; return card; }); for (const card of [...list.children]) if (!cards.includes(card)) card.remove(); cards.forEach((card, index) => { if (list.children[index] !== card) list.insertBefore(card, list.children[index] || null); }); } } function receive(message) { if (message.event === "state") jobs = Object.fromEntries((message.jobs || []).map(job => [job.id, job])); else if (message.event === "job") jobs[message.job.id] = message.job; else if (message.event === "remove_job") delete jobs[message.jobId]; else return; render(); } function retryLater() { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; refresh(); }, reconnectDelay); reconnectDelay = Math.min(30000, reconnectDelay * 2); } async function refresh() { if (refreshing || !call) return; refreshing = true; $("history-retry").disabled = true; try { const state = await call("state"); receive({ event: "state", ...state }); $("history-connection").hidden = true; clearTimeout(reconnectTimer); reconnectTimer = null; reconnectDelay = 1000; } catch (error) { updateText($("history-connection-message"), "Показаны сохранённые записи. Не удалось обновить историю: " + errorText(error.message)); $("history-connection").hidden = false; retryLater(); } finally { refreshing = false; $("history-retry").disabled = false; } } $("history-retry").addEventListener("click", refresh); async function init() { const saved = await chrome.storage.local.get(["jobs", "theme"]); jobs = saved.jobs || {}; theme = ["dark", "light"].includes(saved.theme) ? saved.theme : "auto"; applyTheme(); render(); ({ call } = createUiConnection({ onEvent: receive, onDisconnect: () => { updateText($("history-connection-message"), "Связь с расширением прервалась. Сохранённая история остаётся доступна."); $("history-connection").hidden = false; retryLater(); } })); chrome.storage.onChanged?.addListener((changes, area) => { if (area !== "local" || !changes.theme) return; theme = ["dark", "light"].includes(changes.theme.newValue) ? changes.theme.newValue : "auto"; applyTheme(); }); await refresh(); // Only re-read local state; opening history never launches the native helper. setInterval(() => { if (!document.hidden) refresh(); }, 20000); document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); } init().catch(error => showError(error.message));