Telegram +

Видео, истории и скачивание файлов и другие функции ↴

// ==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 = "&times;";
    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.");
  })();

})();