Telegram Media Downloader

Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content

// ==UserScript==
// @name         Telegram Media Downloader
// @version      1.400
// @namespace    https://t.me/+mg8_ktxbN8UwOTdi
// @description  Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
// @author       TG @TypTems (Enhanced by Claude)
// @license      GNU GPLv3
// @website      https://t.me/+mg8_ktxbN8UwOTdi
// @match        https://web.telegram.org/*
// @match        https://webk.telegram.org/*
// @match        https://webz.telegram.org/*
// @icon         https://img.icons8.com/color/452/telegram-app--v5.png
// ==/UserScript==

(function () {
  const logger = {
    info: (message, fileName = null) => {
      console.log(
        `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
      );
    },
    error: (message, fileName = null) => {
      console.error(
        `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
      );
    },
  };

  const DOWNLOAD_ICON = "\uE95E";
  const FORWARD_ICON = "\uE97A";
  const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  const REFRESH_DELAY = 500;
  const TEL_DOWNLOAD_ATTR = "data-tel-download-id";
  const downloadStates = new Map(); // Track active downloads

  const hashCode = (s) => {
    var h = 0,
      l = s.length,
      i = 0;
    if (l > 0) {
      while (i < l) {
        h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
      }
    }
    return h >>> 0;
  };

  // Modern, minimal UI progress bar
  const createProgressBar = (videoId, fileName, onPauseResume) => {
    const container = document.getElementById(
      "tel-downloader-progress-bar-container"
    );

    const innerContainer = document.createElement("div");
    innerContainer.id = "tel-downloader-progress-" + videoId;
    innerContainer.className = "tel-progress-card";

    const header = document.createElement("div");
    header.className = "tel-progress-header";

    const fileInfo = document.createElement("div");
    fileInfo.className = "tel-progress-file-info";

    const fileIcon = document.createElement("div");
    fileIcon.className = "tel-progress-icon";
    fileIcon.innerHTML = `
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
        <polyline points="7 10 12 15 17 10"></polyline>
        <line x1="12" y1="15" x2="12" y2="3"></line>
      </svg>
    `;

    const fileName_el = document.createElement("div");
    fileName_el.className = "tel-progress-filename";
    fileName_el.textContent = fileName;

    // Pause/Resume button
    const pauseResumeButton = document.createElement("button");
    pauseResumeButton.className = "tel-progress-pause";
    pauseResumeButton.innerHTML = `
      <svg class="tel-pause-icon" width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
        <rect x="6" y="4" width="4" height="16"></rect>
        <rect x="14" y="4" width="4" height="16"></rect>
      </svg>
      <svg class="tel-resume-icon" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="display:none;">
        <polygon points="5 3 19 12 5 21 5 3"></polygon>
      </svg>
    `;
    pauseResumeButton.onclick = onPauseResume;

    const closeButton = document.createElement("button");
    closeButton.className = "tel-progress-close";
    closeButton.innerHTML = `
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <line x1="18" y1="6" x2="6" y2="18"></line>
        <line x1="6" y1="6" x2="18" y2="18"></line>
      </svg>
    `;
    closeButton.onclick = function () {
      // Cancel download if active
      const state = downloadStates.get(videoId);
      if (state && state.abortController) {
        state.abortController.abort();
        downloadStates.delete(videoId);
      }
      innerContainer.style.animation = "tel-slideOut 0.3s ease-out";
      setTimeout(() => {
        if (container.contains(innerContainer)) {
          container.removeChild(innerContainer);
        }
      }, 300);
    };

    fileInfo.appendChild(fileIcon);
    fileInfo.appendChild(fileName_el);
    header.appendChild(fileInfo);
    header.appendChild(pauseResumeButton);
    header.appendChild(closeButton);

    const progressContainer = document.createElement("div");
    progressContainer.className = "tel-progress-container";

    const progressBar = document.createElement("div");
    progressBar.className = "tel-progress-bar";

    const progressFill = document.createElement("div");
    progressFill.className = "tel-progress-fill";

    const progressText = document.createElement("div");
    progressText.className = "tel-progress-text";
    progressText.textContent = "0%";

    progressBar.appendChild(progressFill);
    progressContainer.appendChild(progressBar);
    progressContainer.appendChild(progressText);

    innerContainer.appendChild(header);
    innerContainer.appendChild(progressContainer);
    container.appendChild(innerContainer);

    // Trigger animation
    setTimeout(() => innerContainer.classList.add("tel-show"), 10);
  };

  const updateProgress = (videoId, fileName, progress) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) return;

    innerContainer.querySelector(".tel-progress-filename").textContent = fileName;
    innerContainer.querySelector(".tel-progress-text").textContent = progress + "%";
    innerContainer.querySelector(".tel-progress-fill").style.width = progress + "%";
  };

  const togglePauseState = (videoId, isPaused) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) return;

    const pauseIcon = innerContainer.querySelector(".tel-pause-icon");
    const resumeIcon = innerContainer.querySelector(".tel-resume-icon");
    const progressText = innerContainer.querySelector(".tel-progress-text");
    const currentProgress = innerContainer.querySelector(".tel-progress-fill").style.width;

    if (isPaused) {
      pauseIcon.style.display = "none";
      resumeIcon.style.display = "block";
      innerContainer.classList.add("tel-paused");
      progressText.textContent = `⏸ Paused (${currentProgress})`;
    } else {
      pauseIcon.style.display = "block";
      resumeIcon.style.display = "none";
      innerContainer.classList.remove("tel-paused");
    }
  };

  const completeProgress = (videoId) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) return;

    innerContainer.querySelector(".tel-progress-text").textContent = "✓ Complete";
    innerContainer.querySelector(".tel-progress-fill").style.width = "100%";
    innerContainer.querySelector(".tel-progress-fill").classList.add("tel-complete");

    // Hide pause button on completion
    const pauseBtn = innerContainer.querySelector(".tel-progress-pause");
    if (pauseBtn) pauseBtn.style.display = "none";

    // Auto-remove after 3 seconds
    setTimeout(() => {
      if (innerContainer.parentNode) {
        innerContainer.style.animation = "tel-slideOut 0.3s ease-out";
        setTimeout(() => {
          if (innerContainer.parentNode) {
            innerContainer.parentNode.removeChild(innerContainer);
          }
        }, 300);
      }
    }, 3000);
  };

  const AbortProgress = (videoId) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    if (!innerContainer) return;

    innerContainer.querySelector(".tel-progress-text").textContent = "✗ Failed";
    innerContainer.querySelector(".tel-progress-fill").classList.add("tel-error");

    // Hide pause button on error
    const pauseBtn = innerContainer.querySelector(".tel-progress-pause");
    if (pauseBtn) pauseBtn.style.display = "none";
  };

  const showError = (message) => {
    const container = document.getElementById(
      "tel-downloader-progress-bar-container"
    );
    const errorId = "error-" + Date.now();
    const errorCard = document.createElement("div");
    errorCard.id = errorId;
    errorCard.className = "tel-progress-card tel-error-card";
    errorCard.innerHTML = `
      <div class="tel-progress-header">
        <div class="tel-progress-file-info">
          <div class="tel-progress-icon">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
              <circle cx="12" cy="12" r="10"></circle>
              <line x1="12" y1="8" x2="12" y2="12"></line>
              <line x1="12" y1="16" x2="12.01" y2="16"></line>
            </svg>
          </div>
          <div class="tel-progress-filename">${message}</div>
        </div>
        <button class="tel-progress-close" onclick="this.parentElement.parentElement.remove()">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <line x1="18" y1="6" x2="6" y2="18"></line>
            <line x1="6" y1="6" x2="18" y2="18"></line>
          </svg>
        </button>
      </div>
    `;
    container.appendChild(errorCard);
    setTimeout(() => errorCard.classList.add("tel-show"), 10);
    setTimeout(() => errorCard.remove(), 5000);
  };

  const tel_download_video = (url) => {
    let _blobs = [];
    let _next_offset = 0;
    let _total_size = null;
    let _file_extension = "mp4";
    let _writable = null;
    let _abortController = null;
    let _isPaused = false;

    const videoId =
      (Math.random() + 1).toString(36).substring(2, 10) +
      "_" +
      Date.now().toString();
    let fileName = hashCode(url).toString(36) + "." + _file_extension;

    try {
      const metadata = JSON.parse(
        decodeURIComponent(url.split("/")[url.split("/").length - 1])
      );
      if (metadata.fileName) {
        fileName = metadata.fileName;
      }
    } catch (e) {
      // Invalid JSON string, pass extracting fileName
    }
    logger.info(`URL: ${url}`, fileName);

    const fetchNextPart = () => {
      if (_isPaused) return;

      _abortController = new AbortController();
      downloadStates.set(videoId, {
        abortController: _abortController,
        isPaused: false,
        resume: resumeDownload
      });

      fetch(url, {
        method: "GET",
        headers: {
          Range: `bytes=${_next_offset}-`,
        },
        signal: _abortController.signal,
        "User-Agent":
          "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
      })
        .then((res) => {
          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.startsWith("video/")) {
            throw new Error("Get non video response with MIME type " + mime);
          }
          _file_extension = mime.split("/")[1];
          fileName =
            fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;

          const match = res.headers
            .get("Content-Range")
            .match(contentRangeRegex);

          const startOffset = parseInt(match[1]);
          const endOffset = parseInt(match[2]);
          const totalSize = parseInt(match[3]);

          if (startOffset !== _next_offset) {
            logger.error("Gap detected between responses.", fileName);
            logger.info("Last offset: " + _next_offset, fileName);
            logger.info("New start offset " + match[1], fileName);
            throw "Gap detected between responses.";
          }
          if (_total_size && totalSize !== _total_size) {
            logger.error("Total size differs", fileName);
            throw "Total size differs";
          }

          _next_offset = endOffset + 1;
          _total_size = totalSize;

          logger.info(
            `Get response: ${res.headers.get(
              "Content-Length"
            )} bytes data from ${res.headers.get("Content-Range")}`,
            fileName
          );
          logger.info(
            `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`,
            fileName
          );
          updateProgress(
            videoId,
            fileName,
            ((_next_offset * 100) / _total_size).toFixed(0)
          );
          return res.blob();
        })
        .then((resBlob) => {
          if (_writable !== null) {
            return _writable.write(resBlob);
          } else {
            _blobs.push(resBlob);
          }
        })
        .then(() => {
          if (!_total_size) {
            throw new Error("_total_size is NULL");
          }

          if (_next_offset < _total_size && !_isPaused) {
            fetchNextPart();
          } else if (_next_offset >= _total_size) {
            if (_writable !== null) {
              _writable.close().then(() => {
                logger.info("Download finished", fileName);
              });
            } else {
              save();
            }
            completeProgress(videoId);
            downloadStates.delete(videoId);
          }
        })
        .catch((reason) => {
          if (reason.name === 'AbortError') {
            logger.info("Download paused", fileName);
            return;
          }
          logger.error(reason, fileName);
          AbortProgress(videoId);
          downloadStates.delete(videoId);
        });
    };

    const pauseDownload = () => {
      _isPaused = true;
      if (_abortController) {
        _abortController.abort();
      }
      const state = downloadStates.get(videoId);
      if (state) {
        state.isPaused = true;
      }
      togglePauseState(videoId, true);
      logger.info("Paused at offset: " + _next_offset, fileName);
    };

    const resumeDownload = () => {
      _isPaused = false;
      const state = downloadStates.get(videoId);
      if (state) {
        state.isPaused = false;
      }
      togglePauseState(videoId, false);
      logger.info("Resuming from offset: " + _next_offset, fileName);
      fetchNextPart();
    };

    const togglePause = () => {
      const state = downloadStates.get(videoId);
      if (!state) return;

      if (state.isPaused || _isPaused) {
        resumeDownload();
      } else {
        pauseDownload();
      }
    };

    const save = () => {
      logger.info("Finish downloading blobs", fileName);
      logger.info("Concatenating blobs and downloading...", fileName);

      const blob = new Blob(_blobs, { type: "video/mp4" });
      const blobUrl = window.URL.createObjectURL(blob);

      logger.info("Final blob size: " + blob.size + " bytes", fileName);

      const a = document.createElement("a");
      document.body.appendChild(a);
      a.href = blobUrl;
      a.download = fileName;
      a.click();
      document.body.removeChild(a);
      window.URL.revokeObjectURL(blobUrl);

      logger.info("Download triggered", fileName);
    };

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

    createProgressBar(videoId, fileName, togglePause);

    if (supportsFileSystemAccess) {
      unsafeWindow
        .showSaveFilePicker({
          suggestedName: fileName,
        })
        .then((handle) => {
          handle
            .createWritable()
            .then((writable) => {
              _writable = writable;
              fetchNextPart();
            })
            .catch((err) => {
              logger.error("createWritable error: " + err.message, fileName);
              AbortProgress(videoId);
              showError("Failed to create file: " + err.message);
            });
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            logger.error("showSaveFilePicker error: " + err.message, fileName);
            showError("Save cancelled or failed");
          }
          // Remove progress bar if user cancelled
          const progressEl = document.getElementById("tel-downloader-progress-" + videoId);
          if (progressEl && progressEl.parentNode) {
            progressEl.parentNode.removeChild(progressEl);
          }
          downloadStates.delete(videoId);
        });
    } else {
      fetchNextPart();
    }
  };

  const tel_download_audio = (url) => {
    let _blobs = [];
    let _next_offset = 0;
    let _total_size = null;
    let _writable = null;
    let _abortController = null;
    let _isPaused = false;
    const fileName = hashCode(url).toString(36) + ".ogg";

    const audioId =
      (Math.random() + 1).toString(36).substring(2, 10) +
      "_" +
      Date.now().toString();

    const fetchNextPart = () => {
      if (_isPaused) return;

      _abortController = new AbortController();
      downloadStates.set(audioId, {
        abortController: _abortController,
        isPaused: false,
        resume: resumeDownload
      });

      fetch(url, {
        method: "GET",
        headers: {
          Range: `bytes=${_next_offset}-`,
        },
        signal: _abortController.signal,
      })
        .then((res) => {
          if (res.status !== 206 && res.status !== 200) {
            logger.error(
              "Non 200/206 response was received: " + res.status,
              fileName
            );
            throw new Error("Non 200/206 response: " + res.status);
          }

          const mime = res.headers.get("Content-Type").split(";")[0];
          if (!mime.startsWith("audio/")) {
            logger.error(
              "Get non audio response with MIME type " + mime,
              fileName
            );
            throw new Error("Get non audio response with MIME type " + mime);
          }

          try {
            const match = res.headers
              .get("Content-Range")
              .match(contentRangeRegex);

            const startOffset = parseInt(match[1]);
            const endOffset = parseInt(match[2]);
            const totalSize = parseInt(match[3]);

            if (startOffset !== _next_offset) {
              logger.error("Gap detected between responses.");
              logger.info("Last offset: " + _next_offset);
              logger.info("New start offset " + match[1]);
              throw new Error("Gap detected between responses.");
            }
            if (_total_size && totalSize !== _total_size) {
              logger.error("Total size differs");
              throw new Error("Total size differs");
            }

            _next_offset = endOffset + 1;
            _total_size = totalSize;

            updateProgress(
              audioId,
              fileName,
              ((_next_offset * 100) / _total_size).toFixed(0)
            );
          } finally {
            logger.info(
              `Get response: ${res.headers.get(
                "Content-Length"
              )} bytes data from ${res.headers.get("Content-Range")}`
            );
            return res.blob();
          }
        })
        .then((resBlob) => {
          if (_writable !== null) {
            return _writable.write(resBlob);
          } else {
            _blobs.push(resBlob);
          }
        })
        .then(() => {
          if (_next_offset < _total_size && !_isPaused) {
            fetchNextPart();
          } else if (_next_offset >= _total_size) {
            if (_writable !== null) {
              _writable.close().then(() => {
                logger.info("Download finished", fileName);
              });
            } else {
              save();
            }
            completeProgress(audioId);
            downloadStates.delete(audioId);
          }
        })
        .catch((reason) => {
          if (reason.name === 'AbortError') {
            logger.info("Download paused", fileName);
            return;
          }
          logger.error(reason, fileName);
          AbortProgress(audioId);
          downloadStates.delete(audioId);
        });
    };

    const pauseDownload = () => {
      _isPaused = true;
      if (_abortController) {
        _abortController.abort();
      }
      const state = downloadStates.get(audioId);
      if (state) {
        state.isPaused = true;
      }
      togglePauseState(audioId, true);
      logger.info("Paused at offset: " + _next_offset, fileName);
    };

    const resumeDownload = () => {
      _isPaused = false;
      const state = downloadStates.get(audioId);
      if (state) {
        state.isPaused = false;
      }
      togglePauseState(audioId, false);
      logger.info("Resuming from offset: " + _next_offset, fileName);
      fetchNextPart();
    };

    const togglePause = () => {
      const state = downloadStates.get(audioId);
      if (!state) return;

      if (state.isPaused || _isPaused) {
        resumeDownload();
      } else {
        pauseDownload();
      }
    };

    const save = () => {
      logger.info(
        "Finish downloading blobs. Concatenating blobs and downloading...",
        fileName
      );

      let blob = new Blob(_blobs, { type: "audio/ogg" });
      const blobUrl = window.URL.createObjectURL(blob);

      logger.info("Final blob size in bytes: " + blob.size, fileName);

      blob = 0;

      const a = document.createElement("a");
      document.body.appendChild(a);
      a.href = blobUrl;
      a.download = fileName;
      a.click();
      document.body.removeChild(a);
      window.URL.revokeObjectURL(blobUrl);

      logger.info("Download triggered", fileName);
    };

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

    createProgressBar(audioId, fileName, togglePause);

    if (supportsFileSystemAccess) {
      unsafeWindow
        .showSaveFilePicker({
          suggestedName: fileName,
        })
        .then((handle) => {
          handle
            .createWritable()
            .then((writable) => {
              _writable = writable;
              fetchNextPart();
            })
            .catch((err) => {
              logger.error("createWritable error: " + err.message, fileName);
              AbortProgress(audioId);
              showError("Failed to create file: " + err.message);
            });
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            logger.error("showSaveFilePicker error: " + err.message, fileName);
            showError("Save cancelled or failed");
          }
          const progressEl = document.getElementById("tel-downloader-progress-" + audioId);
          if (progressEl && progressEl.parentNode) {
            progressEl.parentNode.removeChild(progressEl);
          }
          downloadStates.delete(audioId);
        });
    } else {
      fetchNextPart();
    }
  };

  const tel_download_image = (imageUrl) => {
    const fileName =
      (Math.random() + 1).toString(36).substring(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);
  };

  logger.info("Initialized");

  const createDownloadButton = (className, iconHTML, onClick, url = null) => {
    const button = document.createElement("button");
    button.className = className + " tel-download";
    button.setAttribute(TEL_DOWNLOAD_ATTR, url || "");
    button.innerHTML = iconHTML;
    button.setAttribute("type", "button");
    button.setAttribute("title", "Download");
    button.setAttribute("aria-label", "Download");
    button.onclick = onClick;
    return button;
  };

  const shouldAddButton = (container, selector, url) => {
    const existingTelButton = container.querySelector(`.tel-download[${TEL_DOWNLOAD_ATTR}]`);
    const nativeButton = container.querySelector(selector);

    // Remove our button if native exists
    if (nativeButton && existingTelButton) {
      existingTelButton.remove();
      return false;
    }

    // Update if URL changed
    if (existingTelButton) {
      const currentUrl = existingTelButton.getAttribute(TEL_DOWNLOAD_ATTR);
      if (currentUrl !== url) {
        existingTelButton.remove();
        return true;
      }
      return false;
    }

    // Add if no buttons exist
    return !nativeButton;
  };

  // For webz /a/ webapp
  setInterval(() => {
    // Stories
    const storiesContainer = document.getElementById("StoryViewer");
    if (storiesContainer) {
      const storyHeader =
        storiesContainer.querySelector(".GrsJNw3y") ||
        storiesContainer.querySelector(".DropdownMenu")?.parentNode;

      if (storyHeader && !storyHeader.querySelector(".tel-download")) {
        const downloadIcon = document.createElement("i");
        downloadIcon.className = "icon icon-download";
        const button = createDownloadButton(
          "Button TkphaPyQ tiny translucent-white round",
          "",
          () => {
            const video = storiesContainer.querySelector("video");
            const videoSrc =
              video?.src ||
              video?.currentSrc ||
              video?.querySelector("source")?.src;
            if (videoSrc) {
              tel_download_video(videoSrc);
            } else {
              const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
              if (images.length > 0) {
                const imageSrc = images[images.length - 1]?.src;
                if (imageSrc) tel_download_image(imageSrc);
              }
            }
          }
        );
        button.appendChild(downloadIcon);
        storyHeader.insertBefore(
          button,
          storyHeader.querySelector("button")
        );
      }
    }

    const mediaContainer = document.querySelector(
      "#MediaViewer .MediaViewerSlide--active"
    );
    const mediaViewerActions = document.querySelector(
      "#MediaViewer .MediaViewerActions"
    );
    if (!mediaContainer || !mediaViewerActions) return;

    const videoPlayer = mediaContainer.querySelector(
      ".MediaViewerContent > .VideoPlayer"
    );
    const img = mediaContainer.querySelector(".MediaViewerContent > div > img");

    if (videoPlayer) {
      const videoUrl = videoPlayer.querySelector("video").currentSrc;
      const downloadIcon = document.createElement("i");
      downloadIcon.className = "icon icon-download";

      if (shouldAddButton(mediaViewerActions, 'button[title="Download"]:not(.tel-download)', videoUrl)) {
        const button = createDownloadButton(
          "Button smaller translucent-white round",
          "",
          () => tel_download_video(videoPlayer.querySelector("video").currentSrc),
          videoUrl
        );
        button.appendChild(downloadIcon.cloneNode(true));
        mediaViewerActions.prepend(button);
      }

      const controls = videoPlayer.querySelector(".VideoPlayerControls");
      if (controls) {
        const buttons = controls.querySelector(".buttons");
        if (shouldAddButton(buttons, 'button[title="Download"]:not(.tel-download)', videoUrl)) {
          const button = createDownloadButton(
            "Button smaller translucent-white round",
            "",
            () => tel_download_video(videoPlayer.querySelector("video").currentSrc),
            videoUrl
          );
          button.appendChild(downloadIcon.cloneNode(true));
          const spacer = buttons.querySelector(".spacer");
          spacer.after(button);
        }
      }
    } else if (img && img.src) {
      if (shouldAddButton(mediaViewerActions, 'button[title="Download"]:not(.tel-download)', img.src)) {
        const downloadIcon = document.createElement("i");
        downloadIcon.className = "icon icon-download";
        const button = createDownloadButton(
          "Button smaller translucent-white round",
          "",
          () => tel_download_image(img.src),
          img.src
        );
        button.appendChild(downloadIcon);
        mediaViewerActions.prepend(button);
      }
    }
  }, REFRESH_DELAY);

  // For webk /k/ webapp
  setInterval(() => {
    /* Voice Message or Circle Video */
    const pinnedAudio = document.body.querySelector(".pinned-audio");
    let dataMid;
    let downloadButtonPinnedAudio =
      document.body.querySelector("._tel_download_button_pinned_container") ||
      document.createElement("button");
    if (pinnedAudio) {
      dataMid = pinnedAudio.getAttribute("data-mid");
      downloadButtonPinnedAudio.className =
        "btn-icon tgico-download _tel_download_button_pinned_container";
      downloadButtonPinnedAudio.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 &&
        downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid &&
        audioElement.getAttribute("data-mid") === dataMid
      ) {
        downloadButtonPinnedAudio.onclick = (e) => {
          e.stopPropagation();
          const link = audioElement.audio && audioElement.audio.getAttribute("src");
          const isAudio = audioElement.audio && audioElement.audio instanceof HTMLAudioElement;
          if (link) {
            if (isAudio) {
              tel_download_audio(link);
            } else {
              tel_download_video(link);
            }
          }
        };
        downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
        const link = audioElement.audio && audioElement.audio.getAttribute("src");
        if (link) {
          pinnedAudio
            .querySelector(".pinned-container-wrapper-utils")
            .appendChild(downloadButtonPinnedAudio);
        }
      }
    });

    // Stories
    const storiesContainer = document.getElementById("stories-viewer");
    if (storiesContainer) {
      const createStoryDownloadButton = () => {
        const downloadButton = document.createElement("button");
        downloadButton.className = "btn-icon rp tel-download";
        downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
        downloadButton.setAttribute("type", "button");
        downloadButton.setAttribute("title", "Download");
        downloadButton.setAttribute("aria-label", "Download");
        downloadButton.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 downloadButton;
      };

      const storyHeader = storiesContainer.querySelector(
        "[class^='_ViewerStoryHeaderRight']"
      );
      if (storyHeader && !storyHeader.querySelector(".tel-download")) {
        storyHeader.prepend(createStoryDownloadButton());
      }

      const storyFooter = storiesContainer.querySelector(
        "[class^='_ViewerStoryFooterRight']"
      );
      if (storyFooter && !storyFooter.querySelector(".tel-download")) {
        storyFooter.prepend(createStoryDownloadButton());
      }
    }

    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;

    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();
        };
        logger.info("Using native download button");
      }
    }

    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 videoSrc = mediaAspecter.querySelector("video").src;
        const downloadButton = createDownloadButton(
          "btn-icon default__button tgico-download",
          `<span class="tgico">${DOWNLOAD_ICON}</span>`,
          onDownload || (() => tel_download_video(videoSrc)),
          videoSrc
        );
        brControls.prepend(downloadButton);
      }
    } else if (
      mediaAspecter.querySelector("video") &&
      !mediaButtons.querySelector("button.btn-icon.tgico-download")
    ) {
      const videoSrc = mediaAspecter.querySelector("video").src;
      const downloadButton = createDownloadButton(
        "btn-icon tgico-download",
        `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`,
        onDownload || (() => tel_download_video(videoSrc)),
        videoSrc
      );
      mediaButtons.prepend(downloadButton);
    } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
      if (
        !mediaAspecter.querySelector("img.thumbnail") ||
        !mediaAspecter.querySelector("img.thumbnail").src
      ) {
        return;
      }
      const imgSrc = mediaAspecter.querySelector("img.thumbnail").src;
      const downloadButton = createDownloadButton(
        "btn-icon tgico-download",
        `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`,
        onDownload || (() => tel_download_image(imgSrc)),
        imgSrc
      );
      mediaButtons.prepend(downloadButton);
    }
  }, REFRESH_DELAY);

  // Progress bar container and styles
  (function setupProgressBar() {
    const body = document.querySelector("body");

    // Add styles
    const style = document.createElement("style");
    style.textContent = `
      #tel-downloader-progress-bar-container {
        position: fixed;
        bottom: 1rem;
        right: 1rem;
        z-index: ${location.pathname.startsWith("/k/") ? 4 : 1600};
        display: flex;
        flex-direction: column;
        gap: 0.75rem;
        max-width: 380px;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      }

      .tel-progress-card {
        background: rgba(17, 17, 17, 0.95);
        backdrop-filter: blur(20px);
        border: 1px solid rgba(255, 255, 255, 0.1);
        border-radius: 12px;
        padding: 1rem;
        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
        opacity: 0;
        transform: translateX(100%);
        transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
      }

      .tel-progress-card.tel-show {
        opacity: 1;
        transform: translateX(0);
      }

      .tel-error-card {
        background: rgba(220, 38, 38, 0.95);
        border-color: rgba(239, 68, 68, 0.3);
      }

      .tel-progress-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 0.75rem;
        gap: 0.5rem;
      }

      .tel-progress-file-info {
        display: flex;
        align-items: center;
        gap: 0.75rem;
        flex: 1;
        min-width: 0;
      }

      .tel-progress-icon {
        color: #60a5fa;
        flex-shrink: 0;
        display: flex;
        align-items: center;
      }

      .tel-error-card .tel-progress-icon {
        color: #ffffff;
      }

      .tel-progress-filename {
        color: #e5e7eb;
        font-size: 0.875rem;
        font-weight: 500;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }

      .tel-progress-pause {
        background: transparent;
        border: none;
        color: #60a5fa;
        cursor: pointer;
        padding: 0.25rem;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 6px;
        transition: all 0.2s;
        flex-shrink: 0;
      }

      .tel-progress-pause:hover {
        background: rgba(96, 165, 250, 0.1);
        color: #93c5fd;
      }

      .tel-progress-close {
        background: transparent;
        border: none;
        color: #9ca3af;
        cursor: pointer;
        padding: 0.25rem;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 6px;
        transition: all 0.2s;
        flex-shrink: 0;
      }

      .tel-progress-close:hover {
        background: rgba(255, 255, 255, 0.1);
        color: #ffffff;
      }

      .tel-progress-container {
        position: relative;
      }

      .tel-progress-bar {
        height: 6px;
        background: rgba(255, 255, 255, 0.1);
        border-radius: 999px;
        overflow: hidden;
        position: relative;
      }

      .tel-progress-fill {
        height: 100%;
        background: linear-gradient(90deg, #3b82f6, #60a5fa);
        border-radius: 999px;
        transition: width 0.3s ease, background 0.3s ease;
        position: relative;
      }

      .tel-progress-fill::after {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
        animation: tel-shimmer 2s infinite;
      }

      .tel-progress-fill.tel-complete {
        background: linear-gradient(90deg, #10b981, #34d399);
      }

      .tel-progress-fill.tel-complete::after {
        animation: none;
      }

      .tel-progress-fill.tel-error {
        background: linear-gradient(90deg, #ef4444, #f87171);
      }

      .tel-progress-fill.tel-error::after {
        animation: none;
      }

      .tel-progress-card.tel-paused .tel-progress-fill {
        background: linear-gradient(90deg, #f59e0b, #fbbf24);
      }

      .tel-progress-card.tel-paused .tel-progress-fill::after {
        animation: none;
      }

      .tel-progress-text {
        color: #9ca3af;
        font-size: 0.75rem;
        font-weight: 500;
        margin-top: 0.5rem;
        text-align: right;
      }

      @keyframes tel-shimmer {
        0% { transform: translateX(-100%); }
        100% { transform: translateX(100%); }
      }

      @keyframes tel-slideOut {
        to {
          opacity: 0;
          transform: translateX(100%);
        }
      }

      @media (max-width: 480px) {
        #tel-downloader-progress-bar-container {
          left: 1rem;
          right: 1rem;
          max-width: none;
        }
      }
    `;
    document.head.appendChild(style);

    // Add container
    const container = document.createElement("div");
    container.id = "tel-downloader-progress-bar-container";
    body.appendChild(container);
  })();

  logger.info("Completed script setup with pause/resume functionality");
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址