您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Видео, истории и скачивание файлов и другие функции ↴
// ==UserScript== // @name Telegram + // @name:en Telegram + // @namespace by // @version 1.31 // Увеличена версия из-за изменений // @author diorhc // @description Видео, истории и скачивание файлов и другие функции ↴ // @description:en Telegram Downloader and others features ↴ // @match https://web.telegram.org/* // @match https://webk.telegram.org/* // @match https://webz.telegram.org/* // @icon https://www.google.com/s2/favicons?sz=64&domain=telegram.org // @license MIT // @grant none // @grant unsafeWindow // Добавлено явно для использования unsafeWindow // ==/UserScript== (() => { 'use strict'; // --- Logger Utility --- const logger = { info: (msg, file = "") => console.log(`[TelPlus]${file ? ` ${file}:` : ""} ${msg}`), error: (msg, file = "") => console.error(`[TelPlus]${file ? ` ${file}:` : ""} ${msg}`), }; // --- Constants --- const DOWNLOAD_ICON = "\uE95A"; // Иконка загрузки const FORWARD_ICON = "\uE976"; // Иконка пересылки const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/; // Регулярное выражение для Content-Range const UI_REFRESH_DELAY = 500; // Задержка для обновления UI (в мс) const PROGRESS_BAR_REMOVE_DELAY = 3000; // Задержка перед удалением прогресс-бара после завершения/ошибки // --- Utility Functions --- const hashCode = (s) => Array.from(s).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0; // Функция для получения расширения файла из MIME-типа const getExtensionFromMime = (mime) => { if (!mime) return 'bin'; // По умолчанию для бинарных данных const parts = mime.split('/'); if (parts.length > 1) { const subType = parts[1]; switch (subType) { case 'jpeg': return 'jpg'; case 'ogg': return 'ogg'; case 'mp4': return 'mp4'; case 'webm': return 'webm'; case 'gif': return 'gif'; case 'png': return 'png'; case 'webp': return 'webp'; case 'mpeg': return 'mp3'; // Для audio/mpeg default: return subType; } } return 'bin'; }; // --- Progress Bar --- let progressBarContainer = null; function setupProgressBarContainer() { if (progressBarContainer) return; // Контейнер уже создан const body = document.body; progressBarContainer = document.createElement("div"); progressBarContainer.id = "tel-downloader-progress-bar-container"; Object.assign(progressBarContainer.style, { position: "fixed", bottom: "10px", // Отступ снизу right: "10px", // Отступ справа zIndex: location.pathname.startsWith("/k/") ? 4 : 1600, // Z-index в зависимости от версии Telegram Web display: "flex", flexDirection: "column", gap: "8px", // Отступ между прогресс-барами }); body.appendChild(progressBarContainer); logger.info("Progress bar container initialized."); } function createProgressBar(id, fileName) { setupProgressBarContainer(); // Убедимся, что контейнер существует // Удаляем старый прогресс-бар, если он есть для этого ID const existingBar = document.getElementById(`tel-downloader-progress-${id}`); if (existingBar) existingBar.remove(); const isDark = document.documentElement.classList.contains("night") || document.documentElement.classList.contains("theme-dark"); const inner = document.createElement("div"); inner.id = `tel-downloader-progress-${id}`; Object.assign(inner.style, { width: "20rem", marginTop: "0.4rem", padding: "0.6rem", backgroundColor: isDark ? "rgba(0,0,0,0.4)" : "rgba(0,0,0,0.7)", // Чуть темнее фон borderRadius: "8px", // Скругление углов boxShadow: "0 4px 8px rgba(0,0,0,0.2)", // Тень }); const flex = document.createElement("div"); Object.assign(flex.style, { display: "flex", justifyContent: "space-between", alignItems: "center", // Выравнивание по центру marginBottom: "5px", // Отступ под заголовком }); const title = document.createElement("p"); title.className = "filename"; Object.assign(title.style, { margin: 0, color: "white", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexGrow: 1, // Позволяет заголовку занимать доступное пространство marginRight: "10px" // Отступ от кнопки закрытия }); title.innerText = fileName; const close = document.createElement("div"); Object.assign(close.style, { cursor: "pointer", fontSize: "1.2rem", color: isDark ? "#A0A0A0" : "white", // Цвет иконки закрытия fontWeight: "bold", }); close.innerHTML = "×"; close.onclick = () => inner.remove(); // Используем .remove() для удаления const progressBar = document.createElement("div"); progressBar.className = "progress-bar"; Object.assign(progressBar.style, { backgroundColor: "#e2e2e2", position: "relative", width: "100%", height: "1.6rem", borderRadius: "1.6rem", // Полное скругление overflow: "hidden", }); const counter = document.createElement("p"); Object.assign(counter.style, { position: "absolute", zIndex: 5, left: "50%", top: "50%", transform: "translate(-50%, -50%)", margin: 0, color: "black", fontWeight: "bold", // Жирный текст прогресса }); const progressFill = document.createElement("div"); // Переименовано для ясности Object.assign(progressFill.style, { position: "absolute", height: "100%", width: "0%", backgroundColor: "#6093B5", // Цвет прогресса transition: "width 0.3s ease-out", // Плавный переход }); progressBar.append(counter, progressFill); flex.append(title, close); inner.append(flex, progressBar); progressBarContainer.appendChild(inner); updateProgress(id, fileName, 0); // Инициализируем прогресс } function updateProgress(id, fileName, percent) { const inner = document.getElementById(`tel-downloader-progress-${id}`); if (!inner) return; // Проверка на существование inner.querySelector("p.filename").innerText = fileName; const progressBar = inner.querySelector(".progress-bar"); if (!progressBar) return; progressBar.querySelector("p").innerText = `${percent}%`; progressBar.querySelector("div").style.width = `${percent}%`; } function completeProgress(id) { const inner = document.getElementById(`tel-downloader-progress-${id}`); if (!inner) return; const progressBar = inner.querySelector(".progress-bar"); if (!progressBar) return; progressBar.querySelector("p").innerText = "Completed"; progressBar.querySelector("div").style.backgroundColor = "#B6C649"; // Зеленый для завершения progressBar.querySelector("div").style.width = "100%"; setTimeout(() => inner.remove(), PROGRESS_BAR_REMOVE_DELAY); } function abortProgress(id, errorMessage = "Aborted") { // Добавлена причина отмены const inner = document.getElementById(`tel-downloader-progress-${id}`); if (!inner) return; const progressBar = inner.querySelector(".progress-bar"); if (!progressBar) return; progressBar.querySelector("p").innerText = errorMessage; progressBar.querySelector("div").style.backgroundColor = "#D16666"; // Красный для ошибки progressBar.querySelector("div").style.width = "100%"; setTimeout(() => inner.remove(), PROGRESS_BAR_REMOVE_DELAY); } // --- Downloaders --- function tel_download_media_stream(url, type) { let blobs = [], nextOffset = 0, totalSize = null; const id = `${Math.random().toString(36).slice(2, 10)}_${Date.now()}`; let fileName = `${hashCode(url).toString(36)}.${type === 'audio' ? 'ogg' : 'mp4'}`; // Дефолтное расширение // Попытка извлечь имя файла из URL, если оно встроено в JSON или является частью пути try { const urlObj = new URL(url); const lastPathSegment = urlObj.pathname.split('/').pop(); if (lastPathSegment) { try { const decodedSegment = decodeURIComponent(lastPathSegment); const metadata = JSON.parse(decodedSegment); if (metadata.fileName) { fileName = metadata.fileName; } } catch { // Not a JSON string, try to infer extension from path const parts = lastPathSegment.split('.'); if (parts.length > 1 && parts.pop().length <= 5) { // Простая проверка на валидное расширение fileName = lastPathSegment; } } } } catch (e) { logger.error(`Error processing URL for filename: ${e.message}`, fileName); } // Переопределим fileExt после попытки извлечения fileName let fileExt = fileName.split('.').pop() || (type === 'audio' ? 'ogg' : 'mp4'); logger.info(`Starting download for ${type}: ${url}`, fileName); createProgressBar(id, fileName); const fetchNextPart = (writable) => { fetch(url, { method: "GET", headers: { Range: `bytes=${nextOffset}-` }, }) .then(async (res) => { // Добавим async здесь для await res.blob() if (![200, 206].includes(res.status)) throw new Error(`Non 200/206 response was received: ${res.status}`); const mime = res.headers.get("Content-Type")?.split(";")[0]; // ?. для безопасности if (!mime || !mime.startsWith(type + "/")) throw new Error(`Non-${type} MIME: ${mime || 'N/A'}`); fileExt = getExtensionFromMime(mime); // Обновим имя файла с корректным расширением fileName = fileName.replace(/\.\w+$/, `.${fileExt}`); const contentRangeHeader = res.headers.get("Content-Range"); if (res.status === 200 && !contentRangeHeader) { totalSize = parseInt(res.headers.get("Content-Length")); nextOffset = totalSize; // Завершено logger.info(`Full download detected (status 200, no Content-Range), total size: ${totalSize}`, fileName); updateProgress(id, fileName, 100); // 100% сразу return res.blob(); } const match = contentRangeHeader?.match(contentRangeRegex); if (!match) throw new Error("Invalid Content-Range header format."); const start = +match[1], end = +match[2], size = +match[3]; if (start !== nextOffset) { logger.error(`Gap detected. Last offset: ${nextOffset}, New start: ${start}`, fileName); throw new Error("Gap detected between responses."); } if (totalSize !== null && size !== totalSize) { // Убедимся, что totalSize уже не null logger.error(`Total size differs. Expected: ${totalSize}, Got: ${size}`, fileName); throw new Error("Total size differs"); } nextOffset = end + 1; totalSize = size; const percent = ((nextOffset * 100) / totalSize).toFixed(0); updateProgress(id, fileName, percent); return res.blob(); }) .then((blob) => { if (writable) return writable.write(blob); blobs.push(blob); return Promise.resolve(); // Вернуть промис для правильной цепочки }) .then(() => { if (totalSize === null) { // Если totalSize до сих пор null, значит была ошибка или некорректный ответ throw new Error("Total size not determined."); } if (nextOffset < totalSize) { fetchNextPart(writable); } else { if (writable) { writable.close().then(() => { logger.info(`Download finished (File System Access API): ${fileName}`); completeProgress(id); }).catch(err => { logger.error(`Error closing writable: ${err.message}`, fileName); abortProgress(id, "Write Error"); }); } else { saveBlob(); } } }) .catch((err) => { logger.error(`Download error for ${fileName}: ${err.message || err}`, fileName); abortProgress(id, "Download Failed"); }); }; const saveBlob = () => { logger.info(`Concatenating blobs and downloading: ${fileName}`); const blobType = type === 'audio' ? `audio/${fileExt}` : `video/${fileExt}`; const blob = new Blob(blobs, { type: blobType }); const blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); document.body.appendChild(a); a.href = blobUrl; a.download = fileName; a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); logger.info(`Download triggered: ${fileName}`); completeProgress(id); }; const supportsFS = "showSaveFilePicker" in unsafeWindow && (() => { try { return unsafeWindow.self === unsafeWindow.top; } catch { return false; } })(); if (supportsFS) { unsafeWindow .showSaveFilePicker({ suggestedName: fileName, types: [ { description: type === 'audio' ? 'Audio Files' : 'Video Files', accept: type === 'audio' ? { 'audio/*': ['.mp3', '.ogg', '.wav', '.flac'] } : { 'video/*': ['.mp4', '.webm', '.ogg', '.mov', '.gif'] } } ] }) .then((handle) => handle.createWritable().then((writable) => fetchNextPart(writable)) ) .catch((err) => { if (err.name === "AbortError") { logger.info(`User aborted file save dialog for ${fileName}.`, fileName); abortProgress(id, "User Cancelled"); } else { logger.error(`Error with File System Access API for ${fileName}: ${err.message}`, fileName); abortProgress(id, "FS API Error"); } }); } else { fetchNextPart(null); } } // Общие функции для скачивания (используют tel_download_media_stream) function tel_download_video(url) { tel_download_media_stream(url, 'video'); } function tel_download_audio(url) { tel_download_media_stream(url, 'audio'); } function tel_download_image(imageUrl) { const id = `${Math.random().toString(36).slice(2, 10)}_${Date.now()}`; let fileName = `${hashCode(imageUrl).toString(36)}.jpeg`; // Дефолтное расширение // Попытка извлечь имя файла и расширение из URL try { const urlObj = new URL(imageUrl); const pathSegments = urlObj.pathname.split('/'); const lastSegment = pathSegments[pathSegments.length - 1]; if (lastSegment && lastSegment.includes('.')) { const parts = lastSegment.split('.'); const potentialExt = parts[parts.length - 1]; if (potentialExt.length <= 5 && /^[a-zA-Z0-9]+$/.test(potentialExt)) { fileName = lastSegment; // Используем имя из URL, если оно похоже на валидное имя файла } } } catch (e) { logger.error(`Error parsing image URL for filename: ${e.message}`, imageUrl); } createProgressBar(id, fileName); logger.info(`Starting image download: ${imageUrl}`, fileName); fetch(imageUrl) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const contentType = response.headers.get("Content-Type"); // Обновляем расширение файла, если MIME-тип точнее if (contentType && !fileName.includes('.')) { // Если имя файла не содержит расширения const newExt = getExtensionFromMime(contentType); fileName = fileName.replace(/\.\w+$/, '') + `.${newExt}`; // Заменяем на новое } else if (contentType && fileName.includes('.')) { // Если имя уже есть, но mime-тип точнее const currentExt = fileName.split('.').pop().toLowerCase(); const newExt = getExtensionFromMime(contentType); if (currentExt !== newExt && (newExt === 'jpg' && currentExt === 'jpeg' || newExt === 'jpeg' && currentExt === 'jpg')) { // Разрешаем jpg/jpeg взаимозаменяемость } else if (currentExt !== newExt) { fileName = fileName.replace(/\.\w+$/, '') + `.${newExt}`; // Обновляем } } return response.blob(); }) .then(blob => { const blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); document.body.appendChild(a); a.href = blobUrl; a.download = fileName; a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); logger.info(`Image download triggered: ${fileName}`); completeProgress(id); }) .catch(error => { logger.error(`Image download failed: ${error.message}`, fileName); abortProgress(id, "Download Failed"); }); } // --- UI Button Injection --- // Унифицированная функция для добавления кнопки загрузки const addDownloadButton = (container, mediaType, url, prepend = false, specificClass = '') => { // Проверяем, существует ли уже кнопка для данного URL в этом контейнере const existingButton = container.querySelector(`.telplus-download-btn[data-url="${url}"]`); if (existingButton) { // Если кнопка уже есть, но нужно обновить её класс, это можно сделать здесь. // Например, если она была добавлена с одним классом, а теперь нужна другая стилизация. if (specificClass && !existingButton.classList.contains(specificClass)) { existingButton.classList.add(specificClass); } return; // Не добавляем дубликат } const btn = document.createElement("button"); btn.className = "telplus-download-btn"; // Общий класс для идентификации наших кнопок btn.setAttribute("type", "button"); btn.setAttribute("title", "Download"); btn.setAttribute("aria-label", "Download"); btn.setAttribute("data-url", url); // Сохраняем URL для предотвращения дубликатов // Добавляем иконку const iconSpan = document.createElement("span"); iconSpan.className = "tgico button-icon"; // Общий класс для иконок Telegram iconSpan.innerHTML = DOWNLOAD_ICON; btn.appendChild(iconSpan); // Добавляем специфичные классы для стилизации в зависимости от контекста if (container.closest('#MediaViewer') || container.closest('#StoryViewer')) { // webz стили btn.classList.add("Button", "smaller", "translucent-white", "round"); } else { // webk стили btn.classList.add("btn-icon", "tgico-download"); btn.innerHTML += `<div class="c-ripple"></div>`; // Эффект нажатия } if (specificClass) { btn.classList.add(specificClass); } btn.onclick = (e) => { e.stopPropagation(); // Предотвращаем всплытие if (mediaType === 'video') tel_download_video(url); else if (mediaType === 'audio') tel_download_audio(url); else if (mediaType === 'image') tel_download_image(url); }; if (prepend) { container.prepend(btn); } else { container.appendChild(btn); } }; // --- Main Loop for UI Injection --- logger.info("Starting UI injection loop."); setInterval(() => { // --- Webk App Specific (Telegram K) --- // Voice/Circle Audio Download Button document.querySelectorAll("audio-element").forEach((audioElement) => { const bubble = audioElement.closest(".bubble"); const audioSrc = audioElement.audio?.src; if (bubble && audioSrc) { const container = bubble.querySelector(".message-body-wrapper .bubble-content") || bubble.querySelector(".message-bubble-row.voice"); // Возможные контейнеры if (container) { addDownloadButton(container, 'audio', audioSrc, false, '_tel_download_button_pinned_container'); } } }); // Stories Download Button (Webk) const storiesContainerWebk = document.getElementById("stories-viewer"); if (storiesContainerWebk) { const storyHeader = storiesContainerWebk.querySelector("[class^='_ViewerStoryHeaderRight']"); const storyFooter = storiesContainerWebk.querySelector("[class^='_ViewerStoryFooterRight']"); const video = storiesContainerWebk.querySelector("video.media-video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; const imageSrc = storiesContainerWebk.querySelector("img.media-photo")?.src; if (storyHeader) { if (videoSrc) addDownloadButton(storyHeader, 'video', videoSrc, true, 'rp'); else if (imageSrc) addDownloadButton(storyHeader, 'image', imageSrc, true, 'rp'); } if (storyFooter) { if (videoSrc) addDownloadButton(storyFooter, 'video', videoSrc, true, 'rp'); else if (imageSrc) addDownloadButton(storyFooter, 'image', imageSrc, true, 'rp'); } } // Media Viewer Download Buttons (Webk) const mediaContainerWebk = document.querySelector(".media-viewer-whole"); if (mediaContainerWebk) { const mediaAspecter = mediaContainerWebk.querySelector(".media-viewer-movers .media-viewer-aspecter"); const mediaButtons = mediaContainerWebk.querySelector(".media-viewer-topbar .media-viewer-buttons"); if (mediaAspecter && mediaButtons) { // Unhide hidden buttons mediaButtons.querySelectorAll("button.btn-icon.hide").forEach(btn => { btn.classList.remove("hide"); if (btn.textContent === FORWARD_ICON) btn.classList.add("tgico-forward"); if (btn.textContent === DOWNLOAD_ICON) btn.classList.add("tgico-download"); }); const videoElement = mediaAspecter.querySelector("video"); const imgElement = mediaAspecter.querySelector("img.thumbnail"); if (videoElement && videoElement.src) { addDownloadButton(mediaButtons, 'video', videoElement.src, true); // Top bar button const controls = mediaAspecter.querySelector(".default__controls.ckin__controls"); if (controls) { // In-player controls const brControls = controls.querySelector(".bottom-controls .right-controls"); if (brControls) addDownloadButton(brControls, 'video', videoElement.src, true, 'default__button'); } } else if (imgElement && imgElement.src) { addDownloadButton(mediaButtons, 'image', imgElement.src, true); // Top bar button } } } // --- Webz App Specific (Telegram A) --- // Stories Download Button (Webz) const storiesContainerWebz = document.getElementById("StoryViewer"); if (storiesContainerWebz) { const storyHeader = storiesContainerWebz.querySelector(".GrsJNw3y") || storiesContainerWebz.querySelector(".DropdownMenu")?.parentNode; if (storyHeader) { const video = storiesContainerWebz.querySelector("video"); const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src; const images = storiesContainerWebz.querySelectorAll("img.PVZ8TOWS"); const imageSrc = images.length > 0 ? images[images.length - 1]?.src : null; if (videoSrc) addDownloadButton(storyHeader, 'video', videoSrc, true); else if (imageSrc) addDownloadButton(storyHeader, 'image', imageSrc, true); } } // Media Viewer Download Buttons (Webz) const mediaContainerWebz = document.querySelector("#MediaViewer .MediaViewerSlide--active"); const mediaViewerActionsWebz = document.querySelector("#MediaViewer .MediaViewerActions"); if (mediaContainerWebz && mediaViewerActionsWebz) { const videoPlayer = mediaContainerWebz.querySelector(".MediaViewerContent > .VideoPlayer"); const img = mediaContainerWebz.querySelector(".MediaViewerContent > div > img"); if (videoPlayer) { const videoUrl = videoPlayer.querySelector("video")?.currentSrc; if (videoUrl) { const controls = videoPlayer.querySelector(".VideoPlayerControls"); if (controls) { const buttons = controls.querySelector(".buttons"); if (buttons) addDownloadButton(buttons, 'video', videoUrl); } addDownloadButton(mediaViewerActionsWebz, 'video', videoUrl, true); } } else if (img && img.src) { addDownloadButton(mediaViewerActionsWebz, 'image', img.src, true); } } }, UI_REFRESH_DELAY); logger.info("Completed script setup."); // --- Remove Telegram Speed Limit --- // Этот патч может быть сложным и потенциально вызывать проблемы с памятью // для очень больших файлов, так как он загружает весь блоб в память. // Если Telegram меняет свою политику, этот патч может перестать работать // или вызвать другие проблемы. (function removeTelegramSpeedLimit() { if (typeof unsafeWindow === 'undefined' || !unsafeWindow.fetch) { logger.error("unsafeWindow.fetch is not available, cannot apply speed limit patch."); return; } const originalFetch = unsafeWindow.fetch; unsafeWindow.fetch = function (...args) { return originalFetch.apply(this, args).then(async (res) => { // Клонируем ответ до проверки Content-Type, чтобы можно было прочитать тело // без проблем, даже если Content-Type не соответствует нашим критериям. const resClone = res.clone(); const contentType = res.headers.get("Content-Type") || ""; // Применяем патч только для медиа и бинарных файлов if ( /^video\//.test(contentType) || /^audio\//.test(contentType) || contentType === "application/octet-stream" || /^image\//.test(contentType) // Добавлено для изображений ) { try { // Читаем полное тело жадно, чтобы избежать медленных потоков const blob = await resClone.blob(); // Используем клонированный ответ // Копируем заголовки в новый объект Headers, чтобы избежать проблем с неизменяемыми заголовками const headers = new Headers(); res.headers.forEach((v, k) => headers.append(k, v)); return new Response(blob, { status: res.status, statusText: res.statusText, headers, }); } catch (e) { logger.error(`Failed to patch fetch for ${contentType}: ${e.message}`); return res; // В случае ошибки возвращаем оригинальный ответ } } return res; // Возвращаем оригинальный ответ для других типов }); }; logger.info("Telegram speed limit patch applied."); })(); // --- Remove Telegram Ads --- (function removeTelegramAds() { const adSelectors = [ '[class*="Sponsored"]', '[class*="sponsored"]', '[class*="AdBanner"]', '[class*="ad-banner"]', '[data-testid="sponsored-message"]', '[data-testid="ad-banner"]', // Дополнительные селекторы, если будут обнаружены '.ChannelChat > div[data-peer-id][data-message-id]:not([class*="message-"])' // Потенциально скрытые спонсорские сообщения, если они не имеют обычных классов сообщений ]; function removeAds(root = document) { let removedCount = 0; adSelectors.forEach(selector => { root.querySelectorAll(selector).forEach(el => { if (el.parentNode) { // Убедимся, что у элемента есть родитель el.remove(); removedCount++; } }); }); if (removedCount > 0) { logger.info(`Removed ${removedCount} ad elements.`); } } // Initial cleanup removeAds(); // Observe DOM for dynamically inserted ads const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Проверяем, что это элемент DOM removeAds(node); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); logger.info("Telegram ad removal started."); })(); // --- Media Player Keyboard Controls --- // NOTE: Эта часть кода является самоисполняющейся функцией, // которая вызывается только один раз, но `document.addEventListener("keydown", ...)` // должна быть вне этой самоисполняющейся функции или явно вызвана. // Для ясности я перемещу listener наружу или сделаю его частью основной инициализации. logger.info("Setting up media player keyboard controls."); document.addEventListener("keydown", (e) => { // Проверяем, что не печатаем в полях ввода if ( ["INPUT", "TEXTAREA"].includes(e.target.tagName) || e.target.isContentEditable ) return; const mediaViewer = document.querySelector(".media-viewer-whole") || document.querySelector("#MediaViewer"); // Обе версии if (!mediaViewer) return; const video = mediaViewer.querySelector("video"); if (!video) return; // Notification for keyboard controls let notification = document.querySelector(".video-control-notification"); if (!notification) { notification = document.createElement("div"); notification.className = "video-control-notification"; Object.assign(notification.style, { position: "fixed", top: "50%", left: "50%", transform: "translate(-50%, -50%)", backgroundColor: "rgba(0, 0, 0, 0.7)", color: "white", padding: "10px 20px", borderRadius: "5px", fontSize: "18px", opacity: "0", transition: "opacity 0.3s ease", zIndex: "10000", pointerEvents: "none", backdropFilter: "blur(16px) saturate(180%)", // Glassmorphism webkitBackdropFilter: "blur(16px) saturate(180%)", background: "rgba(32, 38, 57, 0.55)", border: "1px solid rgba(255,255,255,0.18)", boxShadow: "0 8px 32px 0 rgba(31, 38, 135, 0.37)", fontFamily: "'Segoe UI', 'Roboto', 'Arial', sans-serif", letterSpacing: "0.01em", minWidth: "120px", maxWidth: "90vw", textAlign: "center", userSelect: "none", }); document.body.appendChild(notification); // Add keyframe styles for notification pulse if not present if (!document.getElementById("video-control-animations")) { const style = document.createElement("style"); style.id = "video-control-animations"; style.textContent = ` @keyframes notification-pulse { 0% { transform: translate(-50%, -50%) scale(0.95); } 50% { transform: translate(-50%, -50%) scale(1.05); } 100% { transform: translate(-50%, -50%) scale(1); } } .notification-pulse { animation: notification-pulse 0.3s ease-in-out; } .video-control-notification { font-weight: bold; text-shadow: 1px 1px 2px rgba(0,0,0,0.8); } `; document.head.appendChild(style); } } let fadeTimeout; const showNotification = (msg) => { notification.innerHTML = msg; notification.style.opacity = "1"; notification.classList.add("notification-pulse"); if (fadeTimeout) cancelAnimationFrame(fadeTimeout); // Отменяем предыдущий let start; function fade(ts) { if (!start) start = ts; if (ts - start > 1500) { // Держим 1.5 секунды notification.style.opacity = "0"; notification.classList.remove("notification-pulse"); } else { fadeTimeout = requestAnimationFrame(fade); } } fadeTimeout = requestAnimationFrame(fade); }; // Keyboard Shortcuts let handledKey = true; // Флаг, указывающий, что клавиша обработана switch (e.code) { case "ArrowRight": video.currentTime = Math.min(video.duration, video.currentTime + 5); showNotification(`<span style="opacity:0.7;">${Math.floor(video.currentTime)}s</span>`); break; case "ArrowLeft": video.currentTime = Math.max(0, video.currentTime - 5); showNotification(`<span style="opacity:0.7;">${Math.floor(video.currentTime)}s</span>`); break; case "ArrowUp": video.volume = Math.min(1, video.volume + 0.1); showNotification(`<b>${Math.round(video.volume * 100)}%</b>`); break; case "ArrowDown": video.volume = Math.max(0, video.volume - 0.1); showNotification(`<b>${Math.round(video.volume * 100)}%</b>`); break; case "KeyM": video.muted = !video.muted; showNotification(video.muted ? "Muted" : "Unmuted"); break; case "KeyP": if (document.pictureInPictureEnabled && !video.disablePictureInPicture) { // Проверяем поддержку PiP if (document.pictureInPictureElement) { document.exitPictureInPicture().then(() => showNotification("Exited PiP")).catch((err) => logger.error(`Error exiting PiP: ${err.message}`)); } else { video.requestPictureInPicture().then(() => showNotification("Entered PiP")).catch((err) => logger.error(`Error entering PiP: ${err.message}`)); } } else { showNotification("PiP not supported"); } break; case "Home": video.currentTime = 0; showNotification(`<span style="opacity:0.7;">0s</span>`); break; default: handledKey = false; // Клавиша не обработана } if (handledKey) { e.preventDefault(); // Предотвращаем дефолтное поведение только если клавиша обработана e.stopPropagation(); // Останавливаем всплытие, чтобы не мешать другим скриптам } }); // --- Video Progress Persistence --- (function () { const STORAGE_KEY = "tg_video_progress"; const load = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); const save = (obj) => localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); let currentIntervalId = null; const observeVideoAndSaveProgress = () => { // Ищем активный элемент медиа-просмотра в обеих версиях Telegram Web const mediaViewer = document.querySelector(".media-viewer-whole") || document.querySelector("#MediaViewer .MediaViewerSlide--active"); if (!mediaViewer) { if (currentIntervalId) { clearInterval(currentIntervalId); currentIntervalId = null; } return; } const nameEl = mediaViewer.querySelector(".media-viewer-name .peer-title") || mediaViewer.querySelector(".media-viewer-filename") || mediaViewer.querySelector(".peer-title"); const dateEl = mediaViewer.querySelector(".media-viewer-date") || mediaViewer.querySelector(".chat-details-link .time-item"); const video = mediaViewer.querySelector("video"); if (!nameEl || !dateEl || !video) { if (currentIntervalId) { clearInterval(currentIntervalId); currentIntervalId = null; } return; } const name = nameEl.textContent.trim(); const date = dateEl.textContent.trim(); const key = `${name} @ ${date}`; let store = load(); // Восстанавливаем прогресс, если он есть if (store[key] && !video.dataset.restored) { video.currentTime = store[key]; video.dataset.restored = "1"; logger.info(`Restored video progress for "${key}" to ${Math.floor(store[key])}s`); } // Запускаем сохранение прогресса только если еще не запущено для этого видео if (!video.dataset.listened) { video.dataset.listened = "1"; // Помечаем, что этот видеоэлемент уже прослушивается if (currentIntervalId) { // Очищаем предыдущий интервал, если был активен clearInterval(currentIntervalId); } currentIntervalId = setInterval(() => { if (!video.paused && !video.ended) { store[key] = video.currentTime; save(store); } }, 2000); // Сохраняем каждые 2 секунды // Удаляем прогресс после завершения video.addEventListener("ended", () => { delete store[key]; save(store); logger.info(`Removed video progress for "${key}" after completion.`); if (currentIntervalId) { // Очищаем интервал при завершении видео clearInterval(currentIntervalId); currentIntervalId = null; } }, { once: true }); // Очищаем интервал при закрытии медиа-просмотра const closeButton = document.querySelector('.media-viewer-close') || document.querySelector('.icon-close'); if (closeButton) { const clearOnClose = () => { if (currentIntervalId) { clearInterval(currentIntervalId); currentIntervalId = null; } closeButton.removeEventListener('click', clearOnClose); // Удаляем слушатель }; closeButton.addEventListener('click', clearOnClose, { once: true }); } } }; // Используем MutationObserver для отслеживания появления или исчезновения медиа-просмотрщика const observer = new MutationObserver(observeVideoAndSaveProgress); observer.observe(document.body, { childList: true, subtree: true }); // Также вызываем при загрузке страницы, если медиа-просмотрщик уже активен observeVideoAndSaveProgress(); logger.info("Video progress persistence enabled."); })(); })();