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

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