Telegram +

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

// ==UserScript==
// @name            Telegram + 
// @namespace       by
// @version         1.2
// @author          diorhc
// @description     Видео, истории и скачивание файлов
// @description:en  Telegram Downloader
// @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==

(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}`
      );
    },
  };
  // Unicode values for icons (used in /k/ app)
  // https://github.com/morethanwords/tweb/blob/master/src/icons.ts
  const DOWNLOAD_ICON = "\uE95A";
  const FORWARD_ICON = "\uE976";
  const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  const REFRESH_DELAY = 500;
  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;
  };

  const createProgressBar = (videoId, fileName) => {
    const isDarkMode =
      document.querySelector("html").classList.contains("night") ||
      document.querySelector("html").classList.contains("theme-dark");
    const container = document.getElementById(
      "tel-downloader-progress-bar-container"
    );
    const innerContainer = document.createElement("div");
    innerContainer.id = "tel-downloader-progress-" + videoId;
    innerContainer.style.width = "20rem";
    innerContainer.style.marginTop = "0.4rem";
    innerContainer.style.padding = "0.6rem";
    innerContainer.style.backgroundColor = isDarkMode
      ? "rgba(0,0,0,0.3)"
      : "rgba(0,0,0,0.6)";

    const flexContainer = document.createElement("div");
    flexContainer.style.display = "flex";
    flexContainer.style.justifyContent = "space-between";

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

    const closeButton = document.createElement("div");
    closeButton.style.cursor = "pointer";
    closeButton.style.fontSize = "1.2rem";
    closeButton.style.color = isDarkMode ? "#8a8a8a" : "white";
    closeButton.innerHTML = "&times;";
    closeButton.onclick = function () {
      container.removeChild(innerContainer);
    };

    const progressBar = document.createElement("div");
    progressBar.className = "progress";
    progressBar.style.backgroundColor = "#e2e2e2";
    progressBar.style.position = "relative";
    progressBar.style.width = "100%";
    progressBar.style.height = "1.6rem";
    progressBar.style.borderRadius = "2rem";
    progressBar.style.overflow = "hidden";

    const counter = document.createElement("p");
    counter.style.position = "absolute";
    counter.style.zIndex = 5;
    counter.style.left = "50%";
    counter.style.top = "50%";
    counter.style.transform = "translate(-50%, -50%)";
    counter.style.margin = 0;
    counter.style.color = "black";
    const progress = document.createElement("div");
    progress.style.position = "absolute";
    progress.style.height = "100%";
    progress.style.width = "0%";
    progress.style.backgroundColor = "#6093B5";

    progressBar.appendChild(counter);
    progressBar.appendChild(progress);
    flexContainer.appendChild(title);
    flexContainer.appendChild(closeButton);
    innerContainer.appendChild(flexContainer);
    innerContainer.appendChild(progressBar);
    container.appendChild(innerContainer);
  };

  const updateProgress = (videoId, fileName, progress) => {
    const innerContainer = document.getElementById(
      "tel-downloader-progress-" + videoId
    );
    innerContainer.querySelector("p.filename").innerText = fileName;
    const progressBar = innerContainer.querySelector("div.progress");
    progressBar.querySelector("p").innerText = progress + "%";
    progressBar.querySelector("div").style.width = progress + "%";
  };

  const 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%";
  };

  const 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%";
  };

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

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

    // Some video src is in format:
    // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
    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 = (_writable) => {
      fetch(url, {
        method: "GET",
        headers: {
          Range: `bytes=${_next_offset}-`,
        },
        "User-Agent":
          "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) {
            _writable.write(resBlob).then(() => {});
          } else {
            _blobs.push(resBlob);
          }
        })
        .then(() => {
          if (!_total_size) {
            throw new Error("_total_size is NULL");
          }

          if (_next_offset < _total_size) {
            fetchNextPart(_writable);
          } else {
            if (_writable !== null) {
              _writable.close().then(() => {
                logger.info("Download finished", fileName);
              });
            } else {
              save();
            }
            completeProgress(videoId);
          }
        })
        .catch((reason) => {
          logger.error(reason, fileName);
          AbortProgress(videoId);
        });
    };

    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);
    };

    // Use either unsafeWindow (for userscript environments) or fallback to window
    const globalWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    
    const supportsFileSystemAccess =
      "showSaveFilePicker" in globalWindow &&
      (() => {
        try {
          return globalWindow.self === globalWindow.top;
        } catch {
          return false;
        }
      })();
    if (supportsFileSystemAccess) {
      globalWindow
        .showSaveFilePicker({
          suggestedName: fileName,
        })
        .then((handle) => {
          handle
            .createWritable()
            .then((writable) => {
              fetchNextPart(writable);
              createProgressBar(videoId);
            })
            .catch((err) => {
              console.error(err.name, err.message);
            });
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            console.error(err.name, err.message);
          }
        });
    } else {
      fetchNextPart(null);
      createProgressBar(videoId);
    }
  };

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

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

          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 "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 "Gap detected between responses.";
            }
            if (_total_size && totalSize !== _total_size) {
              logger.error("Total size differs");
              throw "Total size differs";
            }

            _next_offset = endOffset + 1;
            _total_size = totalSize;
          } 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) {
            _writable.write(resBlob).then(() => {});
          } else {
            _blobs.push(resBlob);
          }
        })
        .then(() => {
          if (_next_offset < _total_size) {
            fetchNextPart(_writable);
          } else {
            if (_writable !== null) {
              _writable.close().then(() => {
                logger.info("Download finished", fileName);
              });
            } else {
              save();
            }
          }
        })
        .catch((reason) => {
          logger.error(reason, fileName);
        });
    };

    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);
    };

    // Use either unsafeWindow (for userscript environments) or fallback to window
    const globalWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    
    const supportsFileSystemAccess =
      "showSaveFilePicker" in globalWindow &&
      (() => {
        try {
          return globalWindow.self === globalWindow.top;
        } catch {
          return false;
        }
      })();
    if (supportsFileSystemAccess) {
      globalWindow
        .showSaveFilePicker({
          suggestedName: fileName,
        })
        .then((handle) => {
          handle
            .createWritable()
            .then((writable) => {
              fetchNextPart(writable);
            })
            .catch((err) => {
              console.error(err.name, err.message);
            });
        })
        .catch((err) => {
          if (err.name !== "AbortError") {
            console.error(err.name, err.message);
          }
        });
    } else {
      fetchNextPart(null);
    }
  };

  const tel_download_image = (imageUrl) => {
    const fileName =
      (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume 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");  

  // For web /k/ webapp
  setInterval(() => {
    // Utility function to safely create download buttons
    const createDownloadBtn = (className, clickHandler) => {
      const btn = document.createElement("button");
      btn.className = className;
      btn.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
      btn.setAttribute("type", "button");
      btn.setAttribute("aria-label", "Download");
      btn.onclick = clickHandler;
      return btn;
    };

    /* Handle Voice Message or Circle Video in pinned container */
    const pinnedAudio = document.body.querySelector(".pinned-audio");
    if (pinnedAudio) {
      const dataMid = pinnedAudio.getAttribute("data-mid");
      let downloadButtonPinnedAudio = document.body.querySelector("._tel_download_button_pinned_container");
      
      if (!downloadButtonPinnedAudio) {
        downloadButtonPinnedAudio = createDownloadBtn("btn-icon tgico-download _tel_download_button_pinned_container");
      }

      const audioElements = document.body.querySelectorAll("audio-element");
      for (const audioElement of audioElements) {
        const bubble = audioElement.closest(".bubble");
        if (!bubble || bubble.querySelector("._tel_download_button_pinned_container")) {
          continue; // Skip if already processed
        }
        
        if (dataMid && downloadButtonPinnedAudio.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;
          
          if (link) {
            downloadButtonPinnedAudio.onclick = (e) => {
              e.stopPropagation();
              isAudio ? tel_download_audio(link) : tel_download_video(link);
            };
            downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
            
            const container = pinnedAudio.querySelector(".pinned-container-wrapper-utils");
            if (container && !container.contains(downloadButtonPinnedAudio)) {
              container.appendChild(downloadButtonPinnedAudio);
            }
          }
        }
      }
    }

    // Handle Stories
    const storiesContainer = document.getElementById("stories-viewer");
    if (storiesContainer) {
      const storyHeader = storiesContainer.querySelector("[class^='_ViewerStoryHeaderRight']");
      
      if (storyHeader && !storyHeader.querySelector(".tel-download")) {
        const downloadButton = createDownloadBtn("btn-icon rp tel-download", () => {
          try {
            // First try to find video
            const video = storiesContainer.querySelector("video.media-video");
            const videoSrc = video?.src || video?.currentSrc || video?.querySelector("source")?.src;
            
            if (videoSrc) {
              tel_download_video(videoSrc);
            } else {
              // Fallback to image
              const imageSrc = storiesContainer.querySelector("img.media-photo")?.src;
              if (imageSrc) tel_download_image(imageSrc);
            }
          } catch (error) {
            logger.error(`Error downloading story content: ${error.message}`);
          }
        });
        
        downloadButton.innerHTML += '<div class="c-ripple"></div>';
        downloadButton.setAttribute("title", "Download");
        storyHeader.prepend(downloadButton);
      }
    }

    // Media Viewer Handler
    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;

    // Use official download button when available
    const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
    let officialDownloadBtn = null;
    
    hiddenButtons.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");
        officialDownloadBtn = btn;
      }
    });

    // Determine the appropriate content type and add download button if needed
    if (mediaAspecter.querySelector(".ckin__player")) {
      // Video player with controls
      const controls = mediaAspecter.querySelector(".default__controls.ckin__controls");
      if (controls && !controls.querySelector(".tel-download")) {
        const brControls = controls.querySelector(".bottom-controls .right-controls");
        if (brControls) {
          const downloadButton = createDownloadBtn("btn-icon default__button tgico-download tel-download", () => {
            if (officialDownloadBtn) {
              officialDownloadBtn.click();
            } else {
              const videoEl = mediaAspecter.querySelector("video");
              if (videoEl && videoEl.src) tel_download_video(videoEl.src);
            }
          });
          
          downloadButton.setAttribute("title", "Download");
          brControls.prepend(downloadButton);
        }
      }
    } else if (mediaAspecter.querySelector("video") && !mediaButtons.querySelector(".tel-download")) {
      // Video or GIF without controls
      const downloadButton = createDownloadBtn("btn-icon tgico-download tel-download", () => {
        if (officialDownloadBtn) {
          officialDownloadBtn.click();
        } else {
          const videoEl = mediaAspecter.querySelector("video");
          if (videoEl && videoEl.src) tel_download_video(videoEl.src);
        }
      });
      
      mediaButtons.prepend(downloadButton);
    } else if (!mediaButtons.querySelector(".tel-download")) {
      // Image content
      const thumbnail = mediaAspecter.querySelector("img.thumbnail");
      if (thumbnail && thumbnail.src) {
        const downloadButton = createDownloadBtn("btn-icon tgico-download tel-download", () => {
          if (officialDownloadBtn) {
            officialDownloadBtn.click();
          } else {
            tel_download_image(thumbnail.src);
          }
        });
        
        downloadButton.setAttribute("title", "Download");
        mediaButtons.prepend(downloadButton);
      }
    }
  }, REFRESH_DELAY);

  // Progress bar container setup
  (function setupProgressBar() {
    const body = document.querySelector("body");
    if (body && !document.getElementById("tel-downloader-progress-bar-container")) {
      const container = document.createElement("div");
      container.id = "tel-downloader-progress-bar-container";
      container.style.position = "fixed";
      container.style.bottom = 0;
      container.style.right = 0;
      container.style.zIndex = location.pathname.startsWith("/k/") ? "4" : "1600";
      body.appendChild(container);
    }
  })();

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

  // Media player keyboard controls
  document.addEventListener('keydown', (e) => {
    // Only process keystrokes when media viewer is open
    const mediaViewer = document.querySelector('.media-viewer-whole');
    if (!mediaViewer) return;
    
    // Find video element in media viewer
    const videoElement = mediaViewer.querySelector('video');
    if (!videoElement) return;
    
    // Ignore keypresses when user is typing in input fields
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
    
    // Create or get notification element
    let notification = document.querySelector('.video-control-notification');
    if (!notification) {
      notification = document.createElement('div');
      notification.className = 'video-control-notification';
      notification.style.position = 'fixed'; // Changed to fixed from absolute for fullscreen
      notification.style.top = '50%';
      notification.style.left = '50%';
      notification.style.transform = 'translate(-50%, -50%)';
      notification.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
      notification.style.color = 'white';
      notification.style.padding = '10px 20px';
      notification.style.borderRadius = '5px';
      notification.style.fontSize = '18px';
      notification.style.opacity = '0';
      notification.style.transition = 'opacity 0.3s ease';
      notification.style.zIndex = '10000'; // Increased z-index
      notification.style.pointerEvents = 'none';
      document.body.appendChild(notification); // Append to body instead of mediaViewer
    }
    
    // Function to show notification with animation
    const showNotification = (message, icon = '') => {
      notification.innerHTML = `${icon} ${message}`;
      notification.style.opacity = '1';
      
      // Add animation class if not already present
      notification.classList.add('notification-pulse');
      
      // Clear previous timeout and set new one
      clearTimeout(notification.fadeTimeout);
      notification.fadeTimeout = setTimeout(() => {
        notification.style.opacity = '0';
        setTimeout(() => notification.classList.remove('notification-pulse'), 300);
      }, 1500);
    };
    
    // Add pulse animation style if not exists
    if (!document.querySelector('#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);
    }
    
    switch (e.code) {      
      case 'ArrowRight':
        // Forward 5 seconds
        e.preventDefault();
        videoElement.currentTime = Math.min(videoElement.duration, videoElement.currentTime + 5);
        showNotification(`Forward 5s (${Math.floor(videoElement.currentTime)}s)`, '⏩');
        break;
        
      case 'ArrowLeft':
        // Rewind 5 seconds
        e.preventDefault();
        videoElement.currentTime = Math.max(0, videoElement.currentTime - 5);
        showNotification(`Rewind 5s (${Math.floor(videoElement.currentTime)}s)`, '⏪');
        break;
        
      case 'ArrowUp':
        // Increase volume by 10%
        e.preventDefault();
        videoElement.volume = Math.min(1, videoElement.volume + 0.1);
        const volumeUpPercent = Math.round(videoElement.volume * 100);
        showNotification(`Volume: ${volumeUpPercent}%`, '🔊');
        break;
        
      case 'ArrowDown':
        // Decrease volume by 10%
        e.preventDefault();
        videoElement.volume = Math.max(0, videoElement.volume - 0.1);
        const volumeDownPercent = Math.round(videoElement.volume * 100);
        showNotification(`Volume: ${volumeDownPercent}%`, '🔉');
        break;
        
      case 'KeyM':
        // Mute/Unmute
        e.preventDefault();
        videoElement.muted = !videoElement.muted;
        showNotification(videoElement.muted ? 'Muted' : 'Unmuted', videoElement.muted ? '🔇' : '🔈');
        break;

      case 'KeyP':
        // Picture-in-Picture toggle
        e.preventDefault();
        if (document.pictureInPictureElement) {
          document.exitPictureInPicture().catch(err => {
            logger.error(`Error exiting Picture-in-Picture: ${err.message}`);
          });
        } else {
          videoElement.requestPictureInPicture().catch(err => {
            logger.error(`Error entering Picture-in-Picture: ${err.message}`);
          });
        }
        showNotification(document.pictureInPictureElement ? 'Exited Picture-in-Picture' : 'Entered Picture-in-Picture', '🖼️') 
        break;

      case 'Home':
        // Jump to start
        e.preventDefault();
        videoElement.currentTime = 0;
        showNotification('Jumped to start (0s)', '⏪');
        break;
              
      default:
        // Don't prevent default for other keys
        return;
    }
    
    (function () {
      'use strict';

      const STORAGE_KEY = 'tg_video_progress';
      const loadProgress = () => JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      const saveProgress = obj => localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));

      const isVisible = el =>
        el.offsetParent !== null &&
        el.offsetWidth > 0 &&
        el.offsetHeight > 0 &&
        window.getComputedStyle(el).visibility !== 'hidden';

      // Слежение за media-viewer
      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 = loadProgress();

        if (store[key] && !video.dataset.restored) {
          video.currentTime = store[key];
          video.dataset.restored = '1';
        }

        if (!video.dataset.listened) {
          video.dataset.listened = '1';
          setInterval(() => {
            if (!video.paused && !video.ended) {
              store[key] = video.currentTime;
              saveProgress(store);
            }
          }, 2000);
        }
      });

      observer.observe(document.body, { childList: true, subtree: true });
    })();
    // Stop event propagation to prevent conflicts with Telegram's own shortcuts
    e.stopPropagation();
  });

})();