Telegram +

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

// ==UserScript==
// @name            Telegram +
// @name:en         Telegram +
// @namespace       by
// @version         1.3
// @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
// ==/UserScript==

(() => {
  // --- Logger Utility ---
  const logger = {
    info: (msg, file = "") =>
      console.log(`[Tel Download]${file ? ` ${file}:` : ""} ${msg}`),
    error: (msg, file = "") =>
      console.error(`[Tel Download]${file ? ` ${file}:` : ""} ${msg}`),
  };

  // --- Constants ---
  const DOWNLOAD_ICON = "\uE95A";
  const FORWARD_ICON = "\uE976";
  const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  const REFRESH_DELAY = 500;

  // --- Utility Functions ---
  const hashCode = (s) =>
    Array.from(s).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0) >>> 0;

  // --- Progress Bar ---
  function createProgressBar(videoId, fileName) {
    const isDark =
      document.documentElement.classList.contains("night") ||
      document.documentElement.classList.contains("theme-dark");
    const container = document.getElementById("tel-downloader-progress-bar-container");
    const inner = document.createElement("div");
    inner.id = `tel-downloader-progress-${videoId}`;
    Object.assign(inner.style, {
      width: "20rem",
      marginTop: "0.4rem",
      padding: "0.6rem",
      backgroundColor: isDark ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.6)",
    });

    const flex = document.createElement("div");
    Object.assign(flex.style, {
      display: "flex",
      justifyContent: "space-between",
    });

    const title = document.createElement("p");
    title.className = "filename";
    title.style.margin = 0;
    title.style.color = "white";
    title.innerText = fileName;

    const close = document.createElement("div");
    Object.assign(close.style, {
      cursor: "pointer",
      fontSize: "1.2rem",
      color: isDark ? "#8a8a8a" : "white",
    });
    close.innerHTML = "&times;";
    close.onclick = () => container.removeChild(inner);

    const progressBar = document.createElement("div");
    progressBar.className = "progress";
    Object.assign(progressBar.style, {
      backgroundColor: "#e2e2e2",
      position: "relative",
      width: "100%",
      height: "1.6rem",
      borderRadius: "2rem",
      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",
    });

    const progress = document.createElement("div");
    Object.assign(progress.style, {
      position: "absolute",
      height: "100%",
      width: "0%",
      backgroundColor: "#6093B5",
    });

    progressBar.append(counter, progress);
    flex.append(title, close);
    inner.append(flex, progressBar);
    container.appendChild(inner);
  }

  function updateProgress(videoId, fileName, percent) {
    const inner = document.getElementById(`tel-downloader-progress-${videoId}`);
    if (!inner) return;
    inner.querySelector("p.filename").innerText = fileName;
    const progressBar = inner.querySelector("div.progress");
    progressBar.querySelector("p").innerText = percent + "%";
    progressBar.querySelector("div").style.width = percent + "%";
  }

  function completeProgress(videoId) {
    const progressBar = document
      .getElementById(`tel-downloader-progress-${videoId}`)
      .querySelector("div.progress");
    progressBar.querySelector("p").innerText = "Completed";
    progressBar.querySelector("div").style.backgroundColor = "#B6C649";
    progressBar.querySelector("div").style.width = "100%";
  }

  function abortProgress(videoId) {
    const progressBar = document
      .getElementById(`tel-downloader-progress-${videoId}`)
      .querySelector("div.progress");
    progressBar.querySelector("p").innerText = "Aborted";
    progressBar.querySelector("div").style.backgroundColor = "#D16666";
    progressBar.querySelector("div").style.width = "100%";
  }

  // --- Downloaders ---
  function tel_download_video(url) {
    let blobs = [],
      nextOffset = 0,
      totalSize = null,
      fileExt = "mp4";
    const videoId = `${Math.random().toString(36).slice(2, 10)}_${Date.now()}`;
    let fileName = hashCode(url).toString(36) + "." + fileExt;

    // Try to extract fileName from metadata
    try {
      const meta = JSON.parse(decodeURIComponent(url.split("/").pop()));
      if (meta.fileName) fileName = meta.fileName;
    } catch {}

    logger.info(`URL: ${url}`, fileName);

    function fetchNextPart(writable) {
      fetch(url, {
        method: "GET",
        headers: { Range: `bytes=${nextOffset}-` },
      })
        .then((res) => {
          if (![200, 206].includes(res.status))
            throw new Error("Non 200/206 response: " + res.status);
          const mime = res.headers.get("Content-Type").split(";")[0];
          if (!mime.startsWith("video/"))
            throw new Error("Non-video MIME: " + mime);
          fileExt = mime.split("/")[1];
          fileName = fileName.replace(/\.\w+$/, "." + fileExt);

          const match = res.headers.get("Content-Range").match(contentRangeRegex);
          const start = +match[1],
            end = +match[2],
            size = +match[3];
          if (start !== nextOffset) throw "Gap detected between responses.";
          if (totalSize && size !== totalSize) throw "Total size differs";
          nextOffset = end + 1;
          totalSize = size;

          updateProgress(
            videoId,
            fileName,
            ((nextOffset * 100) / totalSize).toFixed(0)
          );
          return res.blob();
        })
        .then((blob) => {
          if (writable) return writable.write(blob);
          blobs.push(blob);
        })
        .then(() => {
          if (!totalSize) throw new Error("_total_size is NULL");
          if (nextOffset < totalSize) fetchNextPart(writable);
          else {
            if (writable) writable.close().then(() => logger.info("Download finished", fileName));
            else save();
            completeProgress(videoId);
          }
        })
        .catch((err) => {
          logger.error(err, fileName);
          abortProgress(videoId);
        });
    }

    function save() {
      logger.info("Finish downloading blobs", fileName);
      const blob = new Blob(blobs, { type: "video/mp4" });
      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);
    }

    const supportsFS =
      "showSaveFilePicker" in unsafeWindow &&
      (() => {
        try {
          return unsafeWindow.self === unsafeWindow.top;
        } catch {
          return false;
        }
      })();

    if (supportsFS) {
      unsafeWindow
        .showSaveFilePicker({ suggestedName: fileName })
        .then((handle) =>
          handle.createWritable().then((writable) => {
            fetchNextPart(writable);
            createProgressBar(videoId, fileName);
          })
        )
        .catch((err) => {
          if (err.name !== "AbortError") logger.error(err.message, fileName);
        });
    } else {
      fetchNextPart(null);
      createProgressBar(videoId, fileName);
    }
  }

  function tel_download_audio(url) {
    let blobs = [],
      nextOffset = 0,
      totalSize = null;
    const fileName = hashCode(url).toString(36) + ".ogg";

    function fetchNextPart(writable) {
      fetch(url, {
        method: "GET",
        headers: { Range: `bytes=${nextOffset}-` },
      })
        .then((res) => {
          if (![200, 206].includes(res.status))
            throw new Error("Non 200/206 response: " + res.status);
          const mime = res.headers.get("Content-Type").split(";")[0];
          if (!mime.startsWith("audio/"))
            throw new Error("Non-audio MIME: " + mime);

          const match = res.headers.get("Content-Range").match(contentRangeRegex);
          const start = +match[1],
            end = +match[2],
            size = +match[3];
          if (start !== nextOffset) throw "Gap detected between responses.";
          if (totalSize && size !== totalSize) throw "Total size differs";
          nextOffset = end + 1;
          totalSize = size;
          return res.blob();
        })
        .then((blob) => {
          if (writable) return writable.write(blob);
          blobs.push(blob);
        })
        .then(() => {
          if (nextOffset < totalSize) fetchNextPart(writable);
          else {
            if (writable) writable.close().then(() => logger.info("Download finished", fileName));
            else save();
          }
        })
        .catch((err) => logger.error(err, fileName));
    }

    function save() {
      logger.info("Finish downloading blobs", fileName);
      const blob = new Blob(blobs, { type: "audio/ogg" });
      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);
    }

    const supportsFS =
      "showSaveFilePicker" in unsafeWindow &&
      (() => {
        try {
          return unsafeWindow.self === unsafeWindow.top;
        } catch {
          return false;
        }
      })();

    if (supportsFS) {
      unsafeWindow
        .showSaveFilePicker({ suggestedName: fileName })
        .then((handle) =>
          handle.createWritable().then((writable) => fetchNextPart(writable))
        )
        .catch((err) => {
          if (err.name !== "AbortError") logger.error(err.message, fileName);
        });
    } else {
      fetchNextPart(null);
    }
  }

  function tel_download_image(imageUrl) {
    const fileName = `${Math.random().toString(36).slice(2, 10)}.jpeg`;
    const a = document.createElement("a");
    document.body.appendChild(a);
    a.href = imageUrl;
    a.download = fileName;
    a.click();
    document.body.removeChild(a);
    logger.info("Download triggered", fileName);
  }

  // --- Progress Bar Container Setup ---
  (() => {
    const body = document.body;
    const container = document.createElement("div");
    container.id = "tel-downloader-progress-bar-container";
    Object.assign(container.style, {
      position: "fixed",
      bottom: 0,
      right: 0,
      zIndex: location.pathname.startsWith("/k/") ? 4 : 1600,
    });
    body.appendChild(container);
  })();

  logger.info("Initialized");

  // --- Main Interval: UI Button Injection ---
  setInterval(() => {
    // Voice/Circle Audio Download Button
    const pinnedAudio = document.body.querySelector(".pinned-audio");
    let dataMid;
    let downloadBtn =
      document.body.querySelector("._tel_download_button_pinned_container") ||
      document.createElement("button");
    if (pinnedAudio) {
      dataMid = pinnedAudio.getAttribute("data-mid");
      downloadBtn.className =
        "btn-icon tgico-download _tel_download_button_pinned_container";
      downloadBtn.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
    }
    const audioElements = document.body.querySelectorAll("audio-element");
    audioElements.forEach((audioElement) => {
      const bubble = audioElement.closest(".bubble");
      if (!bubble || bubble.querySelector("._tel_download_button_pinned_container")) return;
      if (
        dataMid &&
        downloadBtn.getAttribute("data-mid") !== dataMid &&
        audioElement.getAttribute("data-mid") === dataMid
      ) {
        const link = audioElement.audio && audioElement.audio.getAttribute("src");
        const isAudio = audioElement.audio && audioElement.audio instanceof HTMLAudioElement;
        downloadBtn.onclick = (e) => {
          e.stopPropagation();
          if (isAudio) tel_download_audio(link);
          else tel_download_video(link);
        };
        downloadBtn.setAttribute("data-mid", dataMid);
        if (link) {
          pinnedAudio
            .querySelector(".pinned-container-wrapper-utils")
            .appendChild(downloadBtn);
        }
      }
    });

    // Stories Download Button
    const storiesContainer = document.getElementById("stories-viewer");
    if (storiesContainer) {
      const createDownloadButton = () => {
        const btn = document.createElement("button");
        btn.className = "btn-icon rp tel-download";
        btn.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
        btn.type = "button";
        btn.title = "Download";
        btn.onclick = () => {
          const video = storiesContainer.querySelector("video.media-video");
          const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src;
          if (videoSrc) tel_download_video(videoSrc);
          else {
            const imageSrc = storiesContainer.querySelector("img.media-photo")?.src;
            if (imageSrc) tel_download_image(imageSrc);
          }
        };
        return btn;
      };
      const storyHeader = storiesContainer.querySelector("[class^='_ViewerStoryHeaderRight']");
      if (storyHeader && !storyHeader.querySelector(".tel-download")) {
        storyHeader.prepend(createDownloadButton());
      }
    }

    // Media Viewer Download Buttons
    const mediaContainer = document.querySelector(".media-viewer-whole");
    if (!mediaContainer) return;
    const mediaAspecter = mediaContainer.querySelector(
      ".media-viewer-movers .media-viewer-aspecter"
    );
    const mediaButtons = mediaContainer.querySelector(
      ".media-viewer-topbar .media-viewer-buttons"
    );
    if (!mediaAspecter || !mediaButtons) return;

    // Unhide hidden buttons and use official download if present
    const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
    let onDownload = null;
    for (const btn of hiddenButtons) {
      btn.classList.remove("hide");
      if (btn.textContent === FORWARD_ICON) btn.classList.add("tgico-forward");
      if (btn.textContent === DOWNLOAD_ICON) {
        btn.classList.add("tgico-download");
        onDownload = () => btn.click();
      }
    }

    // Video player
    if (mediaAspecter.querySelector(".ckin__player")) {
      const controls = mediaAspecter.querySelector(".default__controls.ckin__controls");
      if (controls && !controls.querySelector(".tel-download")) {
        const brControls = controls.querySelector(".bottom-controls .right-controls");
        const btn = document.createElement("button");
        btn.className = "btn-icon default__button tgico-download tel-download";
        btn.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`;
        btn.type = "button";
        btn.title = "Download";
        btn.ariaLabel = "Download";
        btn.onclick = onDownload
          ? onDownload
          : () => tel_download_video(mediaAspecter.querySelector("video").src);
        brControls.prepend(btn);
      }
    } else if (
      mediaAspecter.querySelector("video") &&
      !mediaButtons.querySelector("button.btn-icon.tgico-download")
    ) {
      // Video HTML element
      const btn = document.createElement("button");
      btn.className = "btn-icon tgico-download tel-download";
      btn.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
      btn.type = "button";
      btn.ariaLabel = "Download";
      btn.onclick = onDownload
        ? onDownload
        : () => tel_download_video(mediaAspecter.querySelector("video").src);
      mediaButtons.prepend(btn);
    } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
      // Image
      const img = mediaAspecter.querySelector("img.thumbnail");
      if (!img || !img.src) return;
      const btn = document.createElement("button");
      btn.className = "btn-icon tgico-download tel-download";
      btn.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
      btn.type = "button";
      btn.title = "Download";
      btn.ariaLabel = "Download";
      btn.onclick = onDownload
        ? onDownload
        : () => tel_download_image(img.src);
      mediaButtons.prepend(btn);
    }
  }, REFRESH_DELAY);

  logger.info("Completed script setup.");

  // --- Media Player Keyboard Controls ---
  document.addEventListener("keydown", (e) => {
    const mediaViewer = document.querySelector(".media-viewer-whole");
    if (!mediaViewer) return;
    const video = mediaViewer.querySelector("video");
    if (!video) return;
    if (
      ["INPUT", "TEXTAREA"].includes(e.target.tagName) ||
      e.target.isContentEditable
    )
      return;

    // Notification
    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",
      });
      document.body.appendChild(notification);
    }

    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) {
          notification.style.opacity = "0";
          notification.classList.remove("notification-pulse");
        } else {
          fadeTimeout = requestAnimationFrame(fade);
        }
      }
      fadeTimeout = requestAnimationFrame(fade);
    };

    // Add styles 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);
    }
    if (!document.getElementById("video-control-glassmorphism")) {
      const style = document.createElement("style");
      style.id = "video-control-glassmorphism";
      style.textContent = `
        .video-control-notification {
          backdrop-filter: blur(16px) saturate(180%);
          -webkit-backdrop-filter: blur(16px) saturate(180%);
          background: rgba(32, 38, 57, 0.55);
          border-radius: 16px;
          border: 1px solid rgba(255,255,255,0.18);
          box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
          color: #fff;
          font-family: 'Segoe UI', 'Roboto', 'Arial', sans-serif;
          font-size: 1.1em;
          letter-spacing: 0.01em;
          transition: opacity 0.3s, background 0.3s;
          padding: 18px 32px;
          min-width: 120px;
          max-width: 90vw;
          text-align: center;
          user-select: none;
        }
      `;
      document.head.appendChild(style);
    }

    // Keyboard Shortcuts
    switch (e.code) {
      case "ArrowRight":
        e.preventDefault();
        video.currentTime = Math.min(video.duration, video.currentTime + 5);
        showNotification(`<span style="opacity:0.7;">(${Math.floor(video.currentTime)}s)</span>`);
        break;
      case "ArrowLeft":
        e.preventDefault();
        video.currentTime = Math.max(0, video.currentTime - 5);
        showNotification(`<span style="opacity:0.7;">(${Math.floor(video.currentTime)}s)</span>`);
        break;
      case "ArrowUp":
        e.preventDefault();
        video.volume = Math.min(1, video.volume + 0.1);
        showNotification(`<b>${Math.round(video.volume * 100)}%</b>`);
        break;
      case "ArrowDown":
        e.preventDefault();
        video.volume = Math.max(0, video.volume - 0.1);
        showNotification(`<b>${Math.round(video.volume * 100)}%</b>`);
        break;
      case "KeyM":
        e.preventDefault();
        video.muted = !video.muted;
        showNotification(video.muted ? "Muted" : "Unmuted");
        break;
      case "KeyP":
        e.preventDefault();
        if (document.pictureInPictureElement) {
          document.exitPictureInPicture().catch((err) => logger.error(err.message));
          showNotification("Exited PiP");
        } else {
          video.requestPictureInPicture().catch((err) => logger.error(err.message));
          showNotification("Entered PiP");
        }
        break;
      case "Home":
        e.preventDefault();
        video.currentTime = 0;
        showNotification(`<span style="opacity:0.7;">(0s)</span>`);
        break;
      default:
        return;
    }

    // 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 intervalId = null;
      const observer = new MutationObserver(() => {
        const nameEl = document.querySelector(".media-viewer-name .peer-title");
        const dateEl = document.querySelector(".media-viewer-date");
        const video = document.querySelector("video");
        if (!nameEl || !dateEl || !video) return;
        const name = nameEl.textContent.trim();
        const date = dateEl.textContent.trim();
        const key = `${name} @ ${date}`;
        const store = load();
        if (store[key] && !video.dataset.restored) {
          video.currentTime = store[key];
          video.dataset.restored = "1";
        }
        if (!video.dataset.listened) {
          video.dataset.listened = "1";
          if (intervalId) clearInterval(intervalId);
          intervalId = setInterval(() => {
            if (!video.paused && !video.ended) {
              store[key] = video.currentTime;
              save(store);
            }
          }, 2000);
          video.addEventListener(
            "ended",
            () => {
              delete store[key];
              save(store);
            },
            { once: true }
          );
        }
      });
      observer.observe(document.body, { childList: true, subtree: true });
    })();

    e.stopPropagation();
  });
  
  (function removeTelegramSpeedLimit() {
    // Patch fetch to bypass artificial speed limits on media downloads
    const originalFetch = window.fetch;
    window.fetch = function (...args) {
      return originalFetch.apply(this, args).then(async (res) => {
        const contentType = res.headers.get("Content-Type") || "";
        // Only patch for media and binary files
        if (
          /^video\//.test(contentType) ||
          /^audio\//.test(contentType) ||
          contentType === "application/octet-stream"
        ) {
          // Read the full body eagerly to avoid slow streams
          const blob = await res.clone().blob();
          // Copy headers to a new Headers object to avoid issues with immutable 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,
          });
        }
        return res;
      });
    };
  })();

  (function removeTelegramAds() {
    // Remove sponsored messages and ad banners
    const adSelectors = [
      '[class*="Sponsored"]',
      '[class*="sponsored"]',
      '[class*="AdBanner"]',
      '[class*="ad-banner"]',
      '[data-testid="sponsored-message"]',
      '[data-testid="ad-banner"]'
    ];

    function removeAds(root = document) {
      adSelectors.forEach(selector => {
        root.querySelectorAll(selector).forEach(el => {
          el.remove();
        });
      });
    }

    // Initial cleanup
    removeAds();

    // Observe DOM for dynamically inserted ads
    const observer = new MutationObserver(mutations => {
      for (const mutation of mutations) {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === 1) {
            removeAds(node);
          }
        });
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  })();
  
})();