101 lines
11 KiB
JavaScript
101 lines
11 KiB
JavaScript
(() => {
|
||
let toastHost, toastRoot, tabId = null;
|
||
const dismissed = new Set();
|
||
const toastTimers = new Map();
|
||
const knownJobs = new Map();
|
||
const stages = { preparing: "Подготовка", downloading: "Скачивание", merging: "Объединение дорожек", remuxing: "Подготовка файла", finalizing: "Финализация файла", encoding: "Перекодирование", audio: "Обработка аудио", saving: "Сохранение файла", installing: "Установка", updating: "Обновление компонентов", complete: "Файл сохранён", error: "Ошибка", cancelled: "Отменено", cancelling: "Останавливаю обработку" };
|
||
const isDark = () => document.documentElement.hasAttribute("dark") || document.documentElement.dataset.theme === "dark";
|
||
function updateText(node, value) {
|
||
if (node.textContent === value) return;
|
||
if (node.childNodes.length === 1 && node.firstChild.nodeType === 3) node.firstChild.data = value;
|
||
else node.textContent = value;
|
||
}
|
||
function downloadSizeText(job) {
|
||
const megabytes = value => `${(value / 1048576).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} МБ`;
|
||
if (job.status === "complete" && Number.isFinite(job.result?.size) && job.result.size >= 0) return `Размер файла: ${megabytes(job.result.size)}`;
|
||
const value = job.stage === "saving" ? job.copied : job.downloaded;
|
||
if (!["downloading", "saving"].includes(job.stage) || !Number.isFinite(value) || value < 0) return "";
|
||
const downloaded = `${job.stage === "saving" ? "Сохранено" : "Скачано"} ${megabytes(value)}`;
|
||
return Number.isFinite(job.total) && job.total > 0 ? `${downloaded} из ${megabytes(job.total)}` : downloaded;
|
||
}
|
||
function processingText(job) {
|
||
if (!["merging", "remuxing", "finalizing", "encoding", "audio"].includes(job.stage)) return "";
|
||
const duration = value => {
|
||
const seconds = Math.floor(value);
|
||
return seconds >= 3600 ? `${Math.floor(seconds / 3600)}:${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}` : `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
||
};
|
||
const parts = [];
|
||
if (Number.isFinite(job.processedSeconds) && job.processedSeconds >= 0) parts.push(`Обработано ${duration(job.processedSeconds)}${job.durationSeconds > 0 ? ` из ${duration(job.durationSeconds)}` : ""}`);
|
||
if (Number.isFinite(job.written) && job.written >= 0) parts.push(`Записано ${(job.written / 1048576).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} МБ`);
|
||
return parts.join(" · ");
|
||
}
|
||
const commonStyle = ` :host{font-family:Roboto,"Segoe UI",sans-serif;color-scheme:light;--bg:#fff;--fg:#152334;--muted:#6a7787;--line:#e1e7ee;--track:#edf2f7;--accent:#0788bc}:host([dark]){color-scheme:dark;--bg:#1b2029;--fg:#eef2fa;--muted:#a1adbd;--line:#333e4e;--track:#2b3543;--accent:#59d0f4}*{box-sizing:border-box}button{font:inherit;cursor:pointer}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}`;
|
||
function applyTheme() { toastHost?.toggleAttribute("dark", isDark()); }
|
||
function ensureToasts() {
|
||
if (toastHost?.isConnected) return;
|
||
toastHost = document.createElement("div"); toastHost.id = "ytdl-notifications";
|
||
toastHost.style.cssText = "position:fixed;right:20px;bottom:20px;width:min(365px,calc(100vw - 32px));z-index:2147483647;pointer-events:none";
|
||
toastRoot = toastHost.attachShadow({ mode: "open" });
|
||
toastRoot.innerHTML = `<style>${commonStyle}.transfer{font-size:11px;color:var(--muted);margin-top:8px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.toast{background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:16px;padding:15px;margin-top:9px;box-shadow:0 12px 38px #0003;pointer-events:auto;animation:toast-in .32s cubic-bezier(.2,.8,.2,1) both;position:relative}.top{display:flex;gap:11px;align-items:center}.icon{width:35px;height:35px;display:grid;place-items:center;flex-shrink:0;border-radius:11px;background:color-mix(in srgb,var(--accent) 13%,var(--bg));color:var(--accent);font-size:24px}.copy{min-width:0;flex:1}.title{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.stage{font-size:11px;color:var(--muted);margin-top:4px}.dismiss{border:0;background:transparent;color:var(--muted);font-size:20px;align-self:flex-start;line-height:16px;padding:0 0 0 3px}.track{height:5px;background:var(--track);border-radius:5px;margin-top:13px;overflow:hidden}.fill{height:100%;border-radius:5px;background:linear-gradient(90deg,#149bce,#59ded5);transition:width .3s ease;position:relative;overflow:hidden}.toast[data-status=running] .fill:not(.indeterminate):after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,#fff6,transparent);animation:shine 1.6s linear infinite}.fill.indeterminate{width:35%!important;transition:none;animation:slide 1.7s infinite ease-in-out}.bottom{display:flex;justify-content:space-between;align-items:center;margin-top:9px;font-size:10px;color:var(--muted)}.action{border:0;background:none;color:var(--accent);font-size:11px;padding:0}.complete .icon{background:#35c99820;color:#22b98a}.error .icon{background:#ff656520;color:#e67171}.toast.exit{animation:toast-out .18s ease-in forwards}.encoding .icon{animation:pulse 1.8s infinite}.label{font-variant-numeric:tabular-nums;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:235px}@keyframes toast-in{from{opacity:0;transform:translateY(18px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes toast-out{to{opacity:0;transform:translateX(30px)}}@keyframes shine{from{transform:translateX(-100%)}to{transform:translateX(100%)}}@keyframes slide{0%,100%{transform:translateX(0)}50%{transform:translateX(calc(100% / .35 - 100%))}}@keyframes pulse{50%{box-shadow:0 0 0 5px #31bee912}}@media(max-width:450px){.toast{padding:12px}} </style>`;
|
||
document.documentElement.append(toastHost); applyTheme();
|
||
}
|
||
function dismiss(id) {
|
||
dismissed.add(id); clearTimeout(toastTimers.get(id));
|
||
const toast = toastRoot?.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
||
toast?.classList.add("exit"); setTimeout(() => toast?.remove(), 180);
|
||
}
|
||
function showJob(job) {
|
||
knownJobs.set(job.id, job);
|
||
if (dismissed.has(job.id)) return;
|
||
ensureToasts();
|
||
let toast = toastRoot.querySelector(`[data-id="${CSS.escape(job.id)}"]`);
|
||
if (!toast) {
|
||
toast = document.createElement("article"); toast.className = "toast"; toast.dataset.id = job.id;
|
||
toast.setAttribute("role", "status"); toast.setAttribute("aria-live", "polite");
|
||
toast.innerHTML = '<div class="top"><div class="icon">↓</div><div class="copy"><div class="title"></div><div class="stage"></div></div><button class="dismiss" aria-label="Скрыть уведомление">×</button></div><div class="track"><div class="fill"></div></div><div class="transfer" hidden></div><div class="bottom"><span class="label"></span><button class="action"></button></div>';
|
||
toast.querySelector(".dismiss").addEventListener("click", () => dismiss(job.id));
|
||
toast.querySelector(".action").addEventListener("click", () => {
|
||
const current = knownJobs.get(job.id);
|
||
chrome.runtime.sendMessage({ type: current.status === "complete" ? "REVEAL_JOB" : "CANCEL_JOB", jobId: job.id }).catch(() => {});
|
||
});
|
||
toastRoot.append(toast);
|
||
}
|
||
const className = `toast ${job.stage}`;
|
||
if (toast.className !== className) toast.className = className;
|
||
if (toast.dataset.status !== job.status) toast.dataset.status = job.status;
|
||
updateText(toast.querySelector(".title"), job.title || "YouTubeDL");
|
||
const suffix = job.stage === "downloading" ? ` · ${job.track === "audio" ? "аудио" : "видео"}` : ["encoding", "audio"].includes(job.stage) && job.engine ? ` · ${{ nvenc: "NVIDIA NVENC", amf: "AMD AMF", cpu: "CPU", copy: "без перекодирования" }[job.engine] || job.engine}` : ["merging", "remuxing"].includes(job.stage) && !job.message ? " · без перекодирования" : "";
|
||
updateText(toast.querySelector(".stage"), (job.stage === "complete" && job.kind === "update" ? job.result?.title || "Обновление завершено" : ["updating", "merging", "remuxing", "finalizing", "encoding", "audio"].includes(job.stage) && job.message ? job.message : job.stage === "complete" && job.kind === "dependency" ? "Установка завершена" : stages[job.stage] || "Обработка") + suffix);
|
||
updateText(toast.querySelector(".icon"), { complete: "✓", error: "!", cancelled: "×", encoding: "✦", merging: "⇄", remuxing: "⇄", audio: "♫" }[job.stage] || "↓");
|
||
const fill = toast.querySelector(".fill");
|
||
const width = `${Number.isFinite(job.percent) ? Math.max(0, Math.min(100, job.percent)) : 0}%`;
|
||
if (fill.style.width !== width) fill.style.width = width;
|
||
fill.classList.toggle("indeterminate", job.status === "running" && !Number.isFinite(job.percent));
|
||
toast.querySelector(".track").hidden = ["error", "cancelled"].includes(job.status);
|
||
const transfer = toast.querySelector(".transfer");
|
||
updateText(transfer, downloadSizeText(job) || processingText(job)); transfer.hidden = !transfer.textContent;
|
||
let label = Number.isFinite(job.percent) ? `${Math.round(job.percent)}%` : "";
|
||
if (job.stage === "downloading" && Number.isFinite(job.speed) && job.speed >= 0) label += `${label ? " · " : ""}${(job.speed / 1048576).toFixed(1)} МБ/с`;
|
||
if (["merging", "remuxing", "encoding", "audio"].includes(job.stage) && job.speed) label += `${label ? " · " : ""}${job.speed}`;
|
||
if (["merging", "remuxing", "encoding", "audio"].includes(job.stage) && Number.isFinite(job.eta) && job.eta > 0) label += `${label ? " · " : ""}осталось ≈ ${Math.ceil(job.eta)} с`;
|
||
if (job.status === "complete") label = job.result?.filename || job.result?.title || "Готово";
|
||
if (job.status === "error") label = job.error || "Ошибка";
|
||
const readout = toast.querySelector(".label");
|
||
updateText(readout, label);
|
||
const tooltip = job.error || job.result?.path || label;
|
||
if (readout.title !== tooltip) readout.title = tooltip;
|
||
const action = toast.querySelector(".action");
|
||
updateText(action, job.status === "complete" ? "Открыть папку ↗" : "Отменить");
|
||
action.hidden = !["running", "complete"].includes(job.status) || job.canCancel === false || (["dependency", "update"].includes(job.kind) && job.status === "complete");
|
||
if (job.status !== "running" && !toastTimers.has(job.id)) toastTimers.set(job.id, setTimeout(() => dismiss(job.id), job.status === "error" ? 60000 : 20000));
|
||
}
|
||
chrome.runtime.onMessage.addListener(message => {
|
||
if (message.type === "JOB_PROGRESS") showJob(message.job);
|
||
if (message.type === "REMOVE_JOB") dismiss(message.jobId);
|
||
});
|
||
new MutationObserver(applyTheme).observe(document.documentElement, { attributes: true, attributeFilter: ["dark", "data-theme"] });
|
||
chrome.runtime.sendMessage({ type: "GET_CONTEXT" }).then(context => {
|
||
tabId = context?.tabId; for (const job of context?.jobs || []) if (job.status === "running") showJob(job);
|
||
}).catch(() => {});
|
||
})();
|