// ==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 = "×";
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();
});
})();