154 lines
7.9 KiB
JavaScript
154 lines
7.9 KiB
JavaScript
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 = '<div class="entry-top"><h2 class="entry-title"></h2><span class="entry-status"></span></div><div class="entry-meta"></div><p class="entry-path" hidden></p><pre class="entry-error" hidden></pre><div class="entry-actions" hidden></div>';
|
|
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));
|