Telegram图片视频下载器

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

当前为 2024-06-10 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Telegram Media Downloader
  3. // @name:zh-CN Telegram图片视频下载器
  4. // @version 1.201
  5. // @namespace https://github.com/Neet-Nestor/Telegram-Media-Downloader
  6. // @description Download images, GIFs, videos, and voice messages on the Telegram webapp from private channels that disable downloading and restrict saving content
  7. // @description:zh-cn 从禁止下载的Telegram频道中下载图片、视频及语音消息
  8. // @author Nestor Qin
  9. // @license GNU GPLv3
  10. // @website https://github.com/Neet-Nestor/Telegram-Media-Downloader
  11. // @match https://web.telegram.org/*
  12. // @match https://webk.telegram.org/*
  13. // @match https://webz.telegram.org/*
  14. // @icon https://img.icons8.com/color/452/telegram-app--v5.png
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. const logger = {
  19. info: (message, fileName = null) => {
  20. console.log(
  21. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  22. );
  23. },
  24. error: (message, fileName = null) => {
  25. console.error(
  26. `[Tel Download] ${fileName ? `${fileName}: ` : ""}${message}`
  27. );
  28. },
  29. };
  30. // Unicode values for icons (used in /k/ app)
  31. const DOWNLOAD_ICON = "\uE94B";
  32. const FORWARD_ICON = "\uE960";
  33. const contentRangeRegex = /^bytes (\d+)-(\d+)\/(\d+)$/;
  34. const REFRESH_DELAY = 500;
  35. const hashCode = (s) => {
  36. var h = 0,
  37. l = s.length,
  38. i = 0;
  39. if (l > 0) {
  40. while (i < l) {
  41. h = ((h << 5) - h + s.charCodeAt(i++)) | 0;
  42. }
  43. }
  44. return h >>> 0;
  45. };
  46.  
  47. const createProgressBar = (videoId, fileName) => {
  48. const isDarkMode =
  49. document.querySelector("html").classList.contains("night") ||
  50. document.querySelector("html").classList.contains("theme-dark");
  51. const container = document.getElementById(
  52. "tel-downloader-progress-bar-container"
  53. );
  54. const innerContainer = document.createElement("div");
  55. innerContainer.id = "tel-downloader-progress-" + videoId;
  56. innerContainer.style.width = "20rem";
  57. innerContainer.style.marginTop = "0.4rem";
  58. innerContainer.style.padding = "0.6rem";
  59. innerContainer.style.backgroundColor = isDarkMode
  60. ? "rgba(0,0,0,0.3)"
  61. : "rgba(0,0,0,0.6)";
  62.  
  63. const flexContainer = document.createElement("div");
  64. flexContainer.style.display = "flex";
  65. flexContainer.style.justifyContent = "space-between";
  66.  
  67. const title = document.createElement("p");
  68. title.className = "filename";
  69. title.style.margin = 0;
  70. title.style.color = "white";
  71. title.innerText = fileName;
  72.  
  73. const closeButton = document.createElement("div");
  74. closeButton.style.cursor = "pointer";
  75. closeButton.style.fontSize = "1.2rem";
  76. closeButton.style.color = isDarkMode ? "#8a8a8a" : "white";
  77. closeButton.innerHTML = "&times;";
  78. closeButton.onclick = function () {
  79. container.removeChild(innerContainer);
  80. };
  81.  
  82. const progressBar = document.createElement("div");
  83. progressBar.className = "progress";
  84. progressBar.style.backgroundColor = "#e2e2e2";
  85. progressBar.style.position = "relative";
  86. progressBar.style.width = "100%";
  87. progressBar.style.height = "1.6rem";
  88. progressBar.style.borderRadius = "2rem";
  89. progressBar.style.overflow = "hidden";
  90.  
  91. const counter = document.createElement("p");
  92. counter.style.position = "absolute";
  93. counter.style.zIndex = 5;
  94. counter.style.left = "50%";
  95. counter.style.top = "50%";
  96. counter.style.transform = "translate(-50%, -50%)";
  97. counter.style.margin = 0;
  98. counter.style.color = "black";
  99. const progress = document.createElement("div");
  100. progress.style.position = "absolute";
  101. progress.style.height = "100%";
  102. progress.style.width = "0%";
  103. progress.style.backgroundColor = "#6093B5";
  104.  
  105. progressBar.appendChild(counter);
  106. progressBar.appendChild(progress);
  107. flexContainer.appendChild(title);
  108. flexContainer.appendChild(closeButton);
  109. innerContainer.appendChild(flexContainer);
  110. innerContainer.appendChild(progressBar);
  111. container.appendChild(innerContainer);
  112. };
  113.  
  114. const updateProgress = (videoId, fileName, progress) => {
  115. const innerContainer = document.getElementById(
  116. "tel-downloader-progress-" + videoId
  117. );
  118. innerContainer.querySelector("p.filename").innerText = fileName;
  119. const progressBar = innerContainer.querySelector("div.progress");
  120. progressBar.querySelector("p").innerText = progress + "%";
  121. progressBar.querySelector("div").style.width = progress + "%";
  122. };
  123.  
  124. const completeProgress = (videoId) => {
  125. const progressBar = document
  126. .getElementById("tel-downloader-progress-" + videoId)
  127. .querySelector("div.progress");
  128. progressBar.querySelector("p").innerText = "Completed";
  129. progressBar.querySelector("div").style.backgroundColor = "#B6C649";
  130. progressBar.querySelector("div").style.width = "100%";
  131. };
  132.  
  133. const AbortProgress = (videoId) => {
  134. const progressBar = document
  135. .getElementById("tel-downloader-progress-" + videoId)
  136. .querySelector("div.progress");
  137. progressBar.querySelector("p").innerText = "Aborted";
  138. progressBar.querySelector("div").style.backgroundColor = "#D16666";
  139. progressBar.querySelector("div").style.width = "100%";
  140. };
  141.  
  142. const tel_download_video = (url) => {
  143. let _blobs = [];
  144. let _next_offset = 0;
  145. let _total_size = null;
  146. let _file_extension = "mp4";
  147.  
  148. const videoId =
  149. (Math.random() + 1).toString(36).substring(2, 10) +
  150. "_" +
  151. Date.now().toString();
  152. let fileName = hashCode(url).toString(36) + "." + _file_extension;
  153.  
  154. // Some video src is in format:
  155. // 'stream/{"dcId":5,"location":{...},"size":...,"mimeType":"video/mp4","fileName":"xxxx.MP4"}'
  156. try {
  157. const metadata = JSON.parse(
  158. decodeURIComponent(url.split("/")[url.split("/").length - 1])
  159. );
  160. if (metadata.fileName) {
  161. fileName = metadata.fileName;
  162. }
  163. } catch (e) {
  164. // Invalid JSON string, pass extracting fileName
  165. }
  166. logger.info(`URL: ${url}`, fileName);
  167.  
  168. const fetchNextPart = (_writable) => {
  169. fetch(url, {
  170. method: "GET",
  171. headers: {
  172. Range: `bytes=${_next_offset}-`,
  173. },
  174. "User-Agent":
  175. "User-Agent Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
  176. })
  177. .then((res) => {
  178. if (![200, 206].includes(res.status)) {
  179. throw new Error("Non 200/206 response was received: " + res.status);
  180. }
  181. const mime = res.headers.get("Content-Type").split(";")[0];
  182. if (!mime.startsWith("video/")) {
  183. throw new Error("Get non video response with MIME type " + mime);
  184. }
  185. _file_extension = mime.split("/")[1];
  186. fileName =
  187. fileName.substring(0, fileName.indexOf(".") + 1) + _file_extension;
  188.  
  189. const match = res.headers
  190. .get("Content-Range")
  191. .match(contentRangeRegex);
  192.  
  193. const startOffset = parseInt(match[1]);
  194. const endOffset = parseInt(match[2]);
  195. const totalSize = parseInt(match[3]);
  196.  
  197. if (startOffset !== _next_offset) {
  198. logger.error("Gap detected between responses.", fileName);
  199. logger.info("Last offset: " + _next_offset, fileName);
  200. logger.info("New start offset " + match[1], fileName);
  201. throw "Gap detected between responses.";
  202. }
  203. if (_total_size && totalSize !== _total_size) {
  204. logger.error("Total size differs", fileName);
  205. throw "Total size differs";
  206. }
  207.  
  208. _next_offset = endOffset + 1;
  209. _total_size = totalSize;
  210.  
  211. logger.info(
  212. `Get response: ${res.headers.get(
  213. "Content-Length"
  214. )} bytes data from ${res.headers.get("Content-Range")}`,
  215. fileName
  216. );
  217. logger.info(
  218. `Progress: ${((_next_offset * 100) / _total_size).toFixed(0)}%`,
  219. fileName
  220. );
  221. updateProgress(
  222. videoId,
  223. fileName,
  224. ((_next_offset * 100) / _total_size).toFixed(0)
  225. );
  226. return res.blob();
  227. })
  228. .then((resBlob) => {
  229. if (_writable !== null) {
  230. _writable.write(resBlob).then(() => {});
  231. } else {
  232. _blobs.push(resBlob);
  233. }
  234. })
  235. .then(() => {
  236. if (!_total_size) {
  237. throw new Error("_total_size is NULL");
  238. }
  239.  
  240. if (_next_offset < _total_size) {
  241. fetchNextPart(_writable);
  242. } else {
  243. if (_writable !== null) {
  244. _writable.close().then(() => {
  245. logger.info("Download finished", fileName);
  246. });
  247. } else {
  248. save();
  249. }
  250. completeProgress(videoId);
  251. }
  252. })
  253. .catch((reason) => {
  254. logger.error(reason, fileName);
  255. AbortProgress(videoId);
  256. });
  257. };
  258.  
  259. const save = () => {
  260. logger.info("Finish downloading blobs", fileName);
  261. logger.info("Concatenating blobs and downloading...", fileName);
  262.  
  263. const blob = new Blob(_blobs, { type: "video/mp4" });
  264. const blobUrl = window.URL.createObjectURL(blob);
  265.  
  266. logger.info("Final blob size: " + blob.size + " bytes", fileName);
  267.  
  268. const a = document.createElement("a");
  269. document.body.appendChild(a);
  270. a.href = blobUrl;
  271. a.download = fileName;
  272. a.click();
  273. document.body.removeChild(a);
  274. window.URL.revokeObjectURL(blobUrl);
  275.  
  276. logger.info("Download triggered", fileName);
  277. };
  278.  
  279. const supportsFileSystemAccess =
  280. "showSaveFilePicker" in unsafeWindow &&
  281. (() => {
  282. try {
  283. return unsafeWindow.self === unsafeWindow.top;
  284. } catch {
  285. return false;
  286. }
  287. })();
  288. if (supportsFileSystemAccess) {
  289. unsafeWindow
  290. .showSaveFilePicker({
  291. suggestedName: fileName,
  292. })
  293. .then((handle) => {
  294. handle
  295. .createWritable()
  296. .then((writable) => {
  297. fetchNextPart(writable);
  298. createProgressBar(videoId);
  299. })
  300. .catch((err) => {
  301. console.error(err.name, err.message);
  302. });
  303. })
  304. .catch((err) => {
  305. if (err.name !== "AbortError") {
  306. console.error(err.name, err.message);
  307. }
  308. });
  309. } else {
  310. fetchNextPart(null);
  311. createProgressBar(videoId);
  312. }
  313. };
  314.  
  315. const tel_download_audio = (url) => {
  316. let _blobs = [];
  317. let _next_offset = 0;
  318. let _total_size = null;
  319. const fileName = hashCode(url).toString(36) + ".ogg";
  320.  
  321. const fetchNextPart = (_writable) => {
  322. fetch(url, {
  323. method: "GET",
  324. headers: {
  325. Range: `bytes=${_next_offset}-`,
  326. },
  327. })
  328. .then((res) => {
  329. if (res.status !== 206 && res.status !== 200) {
  330. logger.error(
  331. "Non 200/206 response was received: " + res.status,
  332. fileName
  333. );
  334. return;
  335. }
  336.  
  337. const mime = res.headers.get("Content-Type").split(";")[0];
  338. if (!mime.startsWith("audio/")) {
  339. logger.error(
  340. "Get non audio response with MIME type " + mime,
  341. fileName
  342. );
  343. throw "Get non audio response with MIME type " + mime;
  344. }
  345.  
  346. try {
  347. const match = res.headers
  348. .get("Content-Range")
  349. .match(contentRangeRegex);
  350.  
  351. const startOffset = parseInt(match[1]);
  352. const endOffset = parseInt(match[2]);
  353. const totalSize = parseInt(match[3]);
  354.  
  355. if (startOffset !== _next_offset) {
  356. logger.error("Gap detected between responses.");
  357. logger.info("Last offset: " + _next_offset);
  358. logger.info("New start offset " + match[1]);
  359. throw "Gap detected between responses.";
  360. }
  361. if (_total_size && totalSize !== _total_size) {
  362. logger.error("Total size differs");
  363. throw "Total size differs";
  364. }
  365.  
  366. _next_offset = endOffset + 1;
  367. _total_size = totalSize;
  368. } finally {
  369. logger.info(
  370. `Get response: ${res.headers.get(
  371. "Content-Length"
  372. )} bytes data from ${res.headers.get("Content-Range")}`
  373. );
  374. return res.blob();
  375. }
  376. })
  377. .then((resBlob) => {
  378. if (_writable !== null) {
  379. _writable.write(resBlob).then(() => {});
  380. } else {
  381. _blobs.push(resBlob);
  382. }
  383. })
  384. .then(() => {
  385. if (_next_offset < _total_size) {
  386. fetchNextPart(_writable);
  387. } else {
  388. if (_writable !== null) {
  389. _writable.close().then(() => {
  390. logger.info("Download finished", fileName);
  391. });
  392. } else {
  393. save();
  394. }
  395. }
  396. })
  397. .catch((reason) => {
  398. logger.error(reason, fileName);
  399. });
  400. };
  401.  
  402. const save = () => {
  403. logger.info(
  404. "Finish downloading blobs. Concatenating blobs and downloading...",
  405. fileName
  406. );
  407.  
  408. let blob = new Blob(_blobs, { type: "audio/ogg" });
  409. const blobUrl = window.URL.createObjectURL(blob);
  410.  
  411. logger.info("Final blob size in bytes: " + blob.size, fileName);
  412.  
  413. blob = 0;
  414.  
  415. const a = document.createElement("a");
  416. document.body.appendChild(a);
  417. a.href = blobUrl;
  418. a.download = fileName;
  419. a.click();
  420. document.body.removeChild(a);
  421. window.URL.revokeObjectURL(blobUrl);
  422.  
  423. logger.info("Download triggered", fileName);
  424. };
  425.  
  426. const supportsFileSystemAccess =
  427. "showSaveFilePicker" in unsafeWindow &&
  428. (() => {
  429. try {
  430. return unsafeWindow.self === unsafeWindow.top;
  431. } catch {
  432. return false;
  433. }
  434. })();
  435. if (supportsFileSystemAccess) {
  436. unsafeWindow
  437. .showSaveFilePicker({
  438. suggestedName: fileName,
  439. })
  440. .then((handle) => {
  441. handle
  442. .createWritable()
  443. .then((writable) => {
  444. fetchNextPart(writable);
  445. })
  446. .catch((err) => {
  447. console.error(err.name, err.message);
  448. });
  449. })
  450. .catch((err) => {
  451. if (err.name !== "AbortError") {
  452. console.error(err.name, err.message);
  453. }
  454. });
  455. } else {
  456. fetchNextPart(null);
  457. }
  458. };
  459.  
  460. const tel_download_image = (imageUrl) => {
  461. const fileName =
  462. (Math.random() + 1).toString(36).substring(2, 10) + ".jpeg"; // assume jpeg
  463.  
  464. const a = document.createElement("a");
  465. document.body.appendChild(a);
  466. a.href = imageUrl;
  467. a.download = fileName;
  468. a.click();
  469. document.body.removeChild(a);
  470.  
  471. logger.info("Download triggered", fileName);
  472. };
  473.  
  474. logger.info("Initialized");
  475.  
  476. // For webz /a/ webapp
  477. setInterval(() => {
  478. // Stories
  479. const storiesContainer = document.getElementById("StoryViewer");
  480. if (storiesContainer) {
  481. console.log("storiesContainer");
  482. const createDownloadButton = () => {
  483. console.log("createDownloadButton");
  484. const downloadIcon = document.createElement("i");
  485. downloadIcon.className = "icon icon-download";
  486. const downloadButton = document.createElement("button");
  487. downloadButton.className =
  488. "Button TkphaPyQ tiny translucent-white round tel-download";
  489. downloadButton.appendChild(downloadIcon);
  490. downloadButton.setAttribute("type", "button");
  491. downloadButton.setAttribute("title", "Download");
  492. downloadButton.setAttribute("aria-label", "Download");
  493. downloadButton.onclick = () => {
  494. // 1. Story with video
  495. const video = storiesContainer.querySelector("video");
  496. const videoSrc =
  497. video?.src ||
  498. video?.currentSrc ||
  499. video?.querySelector("source")?.src;
  500. if (videoSrc) {
  501. tel_download_video(videoSrc);
  502. } else {
  503. // 2. Story with image
  504. const images = storiesContainer.querySelectorAll("img.PVZ8TOWS");
  505. if (images.length > 0) {
  506. const imageSrc = images[images.length - 1]?.src;
  507. if (imageSrc) tel_download_image(imageSrc);
  508. }
  509. }
  510. };
  511. return downloadButton;
  512. };
  513.  
  514. const storyHeader =
  515. storiesContainer.querySelector(".GrsJNw3y") ||
  516. storiesContainer.querySelector(".DropdownMenu").parentNode;
  517. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  518. console.log("storyHeader");
  519. storyHeader.insertBefore(
  520. createDownloadButton(),
  521. storyHeader.querySelector("button")
  522. );
  523. }
  524. }
  525.  
  526. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  527. const mediaContainer = document.querySelector(
  528. "#MediaViewer .MediaViewerSlide--active"
  529. );
  530. const mediaViewerActions = document.querySelector(
  531. "#MediaViewer .MediaViewerActions"
  532. );
  533. if (!mediaContainer || !mediaViewerActions) return;
  534.  
  535. // Videos in channels
  536. const videoPlayer = mediaContainer.querySelector(
  537. ".MediaViewerContent > .VideoPlayer"
  538. );
  539. const img = mediaContainer.querySelector(".MediaViewerContent > div > img");
  540. // 1. Video player detected - Video or GIF
  541. // container > .MediaViewerSlides > .MediaViewerSlide > .MediaViewerContent > .VideoPlayer > video[src]
  542. const downloadIcon = document.createElement("i");
  543. downloadIcon.className = "icon icon-download";
  544. const downloadButton = document.createElement("button");
  545. downloadButton.className =
  546. "Button smaller translucent-white round tel-download";
  547. downloadButton.setAttribute("type", "button");
  548. downloadButton.setAttribute("title", "Download");
  549. downloadButton.setAttribute("aria-label", "Download");
  550. if (videoPlayer) {
  551. const videoUrl = videoPlayer.querySelector("video").currentSrc;
  552. downloadButton.setAttribute("data-tel-download-url", videoUrl);
  553. downloadButton.appendChild(downloadIcon);
  554. downloadButton.onclick = () => {
  555. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  556. };
  557.  
  558. // Add download button to video controls
  559. const controls = videoPlayer.querySelector(".VideoPlayerControls");
  560. if (controls) {
  561. const buttons = controls.querySelector(".buttons");
  562. if (!buttons.querySelector("button.tel-download")) {
  563. const spacer = buttons.querySelector(".spacer");
  564. spacer.after(downloadButton);
  565. }
  566. }
  567.  
  568. // Add/Update/Remove download button to topbar
  569. if (mediaViewerActions.querySelector("button.tel-download")) {
  570. const telDownloadButton = mediaViewerActions.querySelector(
  571. "button.tel-download"
  572. );
  573. if (
  574. mediaViewerActions.querySelectorAll('button[title="Download"]')
  575. .length > 1
  576. ) {
  577. // There's existing download button, remove ours
  578. mediaViewerActions.querySelector("button.tel-download").remove();
  579. } else if (
  580. telDownloadButton.getAttribute("data-tel-download-url") !== videoUrl
  581. ) {
  582. // Update existing button
  583. telDownloadButton.onclick = () => {
  584. tel_download_video(videoPlayer.querySelector("video").currentSrc);
  585. };
  586. telDownloadButton.setAttribute("data-tel-download-url", videoUrl);
  587. }
  588. } else if (
  589. !mediaViewerActions.querySelector('button[title="Download"]')
  590. ) {
  591. // Add the button if there's no download button at all
  592. mediaViewerActions.prepend(downloadButton);
  593. }
  594. } else if (img && img.src) {
  595. downloadButton.setAttribute("data-tel-download-url", img.src);
  596. downloadButton.appendChild(downloadIcon);
  597. downloadButton.onclick = () => {
  598. tel_download_image(img.src);
  599. };
  600.  
  601. // Add/Update/Remove download button to topbar
  602. if (mediaViewerActions.querySelector("button.tel-download")) {
  603. const telDownloadButton = mediaViewerActions.querySelector(
  604. "button.tel-download"
  605. );
  606. if (
  607. mediaViewerActions.querySelectorAll('button[title="Download"]')
  608. .length > 1
  609. ) {
  610. // There's existing download button, remove ours
  611. mediaViewerActions.querySelector("button.tel-download").remove();
  612. } else if (
  613. telDownloadButton.getAttribute("data-tel-download-url") !== img.src
  614. ) {
  615. // Update existing button
  616. telDownloadButton.onclick = () => {
  617. tel_download_image(img.src);
  618. };
  619. telDownloadButton.setAttribute("data-tel-download-url", img.src);
  620. }
  621. } else if (
  622. !mediaViewerActions.querySelector('button[title="Download"]')
  623. ) {
  624. // Add the button if there's no download button at all
  625. mediaViewerActions.prepend(downloadButton);
  626. }
  627. }
  628. }, REFRESH_DELAY);
  629.  
  630. // For webk /k/ webapp
  631. setInterval(() => {
  632. /* Voice Message */
  633. const pinnedAudio = document.body.querySelector(".pinned-audio");
  634. let dataMid;
  635. let downloadButtonPinnedAudio =
  636. document.body.querySelector("._tel_download_button_pinned_container") ||
  637. document.createElement("button");
  638. if (pinnedAudio) {
  639. dataMid = pinnedAudio.getAttribute("data-mid");
  640. downloadButtonPinnedAudio.className =
  641. "btn-icon tgico-download _tel_download_button_pinned_container";
  642. downloadButtonPinnedAudio.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  643. }
  644. const voiceMessages = document.body.querySelectorAll("audio-element");
  645. voiceMessages.forEach((voiceMessage) => {
  646. const bubble = voiceMessage.closest(".bubble");
  647. if (
  648. !bubble ||
  649. bubble.querySelector("._tel_download_button_pinned_container")
  650. ) {
  651. return; /* Skip if there's already a download button */
  652. }
  653. if (
  654. dataMid &&
  655. downloadButtonPinnedAudio.getAttribute("data-mid") !== dataMid &&
  656. voiceMessage.getAttribute("data-mid") === dataMid
  657. ) {
  658. downloadButtonPinnedAudio.onclick = (e) => {
  659. e.stopPropagation();
  660. tel_download_audio(link);
  661. };
  662. downloadButtonPinnedAudio.setAttribute("data-mid", dataMid);
  663. const link =
  664. voiceMessage.audio && voiceMessage.audio.getAttribute("src");
  665. if (link) {
  666. pinnedAudio
  667. .querySelector(".pinned-container-wrapper-utils")
  668. .appendChild(downloadButtonPinnedAudio);
  669. }
  670. }
  671. });
  672.  
  673. // Stories
  674. const storiesContainer = document.getElementById("stories-viewer");
  675. if (storiesContainer) {
  676. const createDownloadButton = () => {
  677. const downloadButton = document.createElement("button");
  678. downloadButton.className = "btn-icon rp tel-download";
  679. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span><div class="c-ripple"></div>`;
  680. downloadButton.setAttribute("type", "button");
  681. downloadButton.setAttribute("title", "Download");
  682. downloadButton.setAttribute("aria-label", "Download");
  683. downloadButton.onclick = () => {
  684. // 1. Story with video
  685. const video = storiesContainer.querySelector("video.media-video");
  686. const videoSrc =
  687. video?.src ||
  688. video?.currentSrc ||
  689. video?.querySelector("source")?.src;
  690. if (videoSrc) {
  691. tel_download_video(videoSrc);
  692. } else {
  693. // 2. Story with image
  694. const imageSrc =
  695. storiesContainer.querySelector("img.media-photo")?.src;
  696. if (imageSrc) tel_download_image(imageSrc);
  697. }
  698. };
  699. return downloadButton;
  700. };
  701.  
  702. const storyHeader = storiesContainer.querySelector(
  703. "[class^='_ViewerStoryHeaderRight']"
  704. );
  705. if (storyHeader && !storyHeader.querySelector(".tel-download")) {
  706. storyHeader.prepend(createDownloadButton());
  707. }
  708.  
  709. const storyFooter = storiesContainer.querySelector(
  710. "[class^='_ViewerStoryFooterRight']"
  711. );
  712. if (storyFooter && !storyFooter.querySelector(".tel-download")) {
  713. storyFooter.prepend(createDownloadButton());
  714. }
  715. }
  716.  
  717. // All media opened are located in .media-viewer-movers > .media-viewer-aspecter
  718. const mediaContainer = document.querySelector(".media-viewer-whole");
  719. if (!mediaContainer) return;
  720. const mediaAspecter = mediaContainer.querySelector(
  721. ".media-viewer-movers .media-viewer-aspecter"
  722. );
  723. const mediaButtons = mediaContainer.querySelector(
  724. ".media-viewer-topbar .media-viewer-buttons"
  725. );
  726. if (!mediaAspecter || !mediaButtons) return;
  727.  
  728. // Query hidden buttons and unhide them
  729. const hiddenButtons = mediaButtons.querySelectorAll("button.btn-icon.hide");
  730. let onDownload = null;
  731. for (const btn of hiddenButtons) {
  732. btn.classList.remove("hide");
  733. if (btn.textContent === FORWARD_ICON) {
  734. btn.classList.add("tgico-forward");
  735. }
  736. if (btn.textContent === DOWNLOAD_ICON) {
  737. btn.classList.add("tgico-download");
  738. // Use official download buttons
  739. onDownload = () => {
  740. btn.click();
  741. };
  742. logger.info("onDownload", onDownload);
  743. }
  744. }
  745.  
  746. if (mediaAspecter.querySelector(".ckin__player")) {
  747. // 1. Video player detected - Video and it has finished initial loading
  748. // container > .ckin__player > video[src]
  749.  
  750. // add download button to videos
  751. const controls = mediaAspecter.querySelector(
  752. ".default__controls.ckin__controls"
  753. );
  754. if (controls && !controls.querySelector(".tel-download")) {
  755. const brControls = controls.querySelector(
  756. ".bottom-controls .right-controls"
  757. );
  758. const downloadButton = document.createElement("button");
  759. downloadButton.className =
  760. "btn-icon default__button tgico-download tel-download";
  761. downloadButton.innerHTML = `<span class="tgico">${DOWNLOAD_ICON}</span>`;
  762. downloadButton.setAttribute("type", "button");
  763. downloadButton.setAttribute("title", "Download");
  764. downloadButton.setAttribute("aria-label", "Download");
  765. if (onDownload) {
  766. downloadButton.onclick = onDownload;
  767. } else {
  768. downloadButton.onclick = () => {
  769. tel_download_video(mediaAspecter.querySelector("video").src);
  770. };
  771. }
  772. brControls.prepend(downloadButton);
  773. }
  774. } else if (
  775. mediaAspecter.querySelector("video") &&
  776. mediaAspecter.querySelector("video") &&
  777. !mediaButtons.querySelector("button.btn-icon.tgico-download")
  778. ) {
  779. // 2. Video HTML element detected, could be either GIF or unloaded video
  780. // container > video[src]
  781. const downloadButton = document.createElement("button");
  782. downloadButton.className = "btn-icon tgico-download tel-download";
  783. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  784. downloadButton.setAttribute("type", "button");
  785. downloadButton.setAttribute("title", "Download");
  786. downloadButton.setAttribute("aria-label", "Download");
  787. if (onDownload) {
  788. downloadButton.onclick = onDownload;
  789. } else {
  790. downloadButton.onclick = () => {
  791. tel_download_video(mediaAspecter.querySelector("video").src);
  792. };
  793. }
  794. mediaButtons.prepend(downloadButton);
  795. } else if (!mediaButtons.querySelector("button.btn-icon.tgico-download")) {
  796. // 3. Image without download button detected
  797. // container > img.thumbnail
  798. if (
  799. !mediaAspecter.querySelector("img.thumbnail") ||
  800. !mediaAspecter.querySelector("img.thumbnail").src
  801. ) {
  802. return;
  803. }
  804. const downloadButton = document.createElement("button");
  805. downloadButton.className = "btn-icon tgico-download tel-download";
  806. downloadButton.innerHTML = `<span class="tgico button-icon">${DOWNLOAD_ICON}</span>`;
  807. downloadButton.setAttribute("type", "button");
  808. downloadButton.setAttribute("title", "Download");
  809. downloadButton.setAttribute("aria-label", "Download");
  810. if (onDownload) {
  811. downloadButton.onclick = onDownload;
  812. } else {
  813. downloadButton.onclick = () => {
  814. tel_download_image(mediaAspecter.querySelector("img.thumbnail").src);
  815. };
  816. }
  817. mediaButtons.prepend(downloadButton);
  818. }
  819. }, REFRESH_DELAY);
  820.  
  821. // Progress bar container setup
  822. (function setupProgressBar() {
  823. const body = document.querySelector("body");
  824. const container = document.createElement("div");
  825. container.id = "tel-downloader-progress-bar-container";
  826. container.style.position = "fixed";
  827. container.style.bottom = 0;
  828. container.style.right = 0;
  829. if (location.pathname.startsWith("/k/")) {
  830. container.style.zIndex = 4;
  831. } else {
  832. container.style.zIndex = 1600;
  833. }
  834. body.appendChild(container);
  835. })();
  836. })();