4chan Gallery

4chan grid-based image gallery with zoom mode support for threads that allows you to browse images, and soundposts (images with sounds, webms with sounds) along with other utility features.

  1. // ==UserScript==
  2. // @name 4chan Gallery
  3. // @namespace http://tampermonkey.net/
  4. // @version 2025-01-12 (3.6)
  5. // @description 4chan grid-based image gallery with zoom mode support for threads that allows you to browse images, and soundposts (images with sounds, webms with sounds) along with other utility features.
  6. // @author TheDarkEnjoyer
  7. // @match https://boards.4chan.org/*/thread/*
  8. // @match https://boards.4chan.org/*/archive
  9. // @match https://boards.4channel.org/*/thread/*
  10. // @match https://boards.4channel.org/*/archive
  11. // @match https://warosu.org/*/*
  12. // @match https://archived.moe/*/*
  13. // @match https://archive.palanq.win/*/*
  14. // @match https://archive.4plebs.org/*/*
  15. // @match https://desuarchive.org/*/*
  16. // @match https://thebarchive.com/*/*
  17. // @match https://archiveofsins.com/*/*
  18. // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
  19. // @grant none
  20. // @license GNU GPLv3
  21. // ==/UserScript==
  22.  
  23. (function () {
  24. "use strict";
  25. // injectVideoJS();
  26. const defaultSettings = {
  27. Load_High_Res_Images_By_Default: {
  28. value: false,
  29. info: "When opening the gallery, load high quality images by default (no thumbnails)",
  30. },
  31. Add_Placeholder_Image_For_Zoom_Mode: {
  32. value: true,
  33. info: "Add a placeholder image for zoom mode so even if the thread has no images, you can still open the zoom mode",
  34. },
  35. Play_Webms_On_Hover: {
  36. value: true,
  37. info: "Autoplay webms on hover, pause on mouse leave",
  38. },
  39. Switch_Catbox_To_Pixstash_For_Soundposts: {
  40. value: false,
  41. info: "Switch all catbox.moe links to pixstash.moe links for soundposts",
  42. },
  43. Show_Arrow_Buttons_In_Zoom_Mode: {
  44. value: true,
  45. info: "Show clickable arrow buttons on screen edges in zoom mode",
  46. },
  47. Grid_Columns: {
  48. value: 3,
  49. info: "Number of columns in the grid view",
  50. },
  51. Grid_Cell_Max_Height: {
  52. value: 200,
  53. info: "Maximum height of each cell in pixels",
  54. },
  55. Embed_External_Links: {
  56. value: false,
  57. info: "Embed catbox/pixstash links found in post comments",
  58. },
  59. Strictly_Load_GIFs_As_Thumbnails_On_Hover: {
  60. value: false,
  61. info: "Only load GIF thumbnails until hovered"
  62. },
  63. Open_Close_Gallery_Key: {
  64. value: "i",
  65. info: "Key to open/close the gallery"
  66. },
  67. Hide_Gallery_Button: {
  68. value: false,
  69. info: "Hide the gallery button (You can still open the gallery with the keybind, default is 'i')"
  70. },
  71. };
  72.  
  73. let threadURL = window.location.href;
  74. let lastScrollPosition = 0;
  75. let gallerySize = { width: 0, height: 0 };
  76. let gridContainer; // Add this line
  77.  
  78. // store settings in local storage
  79. if (!localStorage.getItem("gallerySettings")) {
  80. localStorage.setItem("gallerySettings", JSON.stringify(defaultSettings));
  81. }
  82. let settings = JSON.parse(localStorage.getItem("gallerySettings"));
  83.  
  84. // check if settings has all the keys from defaultSettings, if not, add the missing keys
  85. let missingSetting = false;
  86. for (const setting in defaultSettings) {
  87. if (!settings.hasOwnProperty(setting)) {
  88. settings[setting] = defaultSettings[setting];
  89. missingSetting = true;
  90. }
  91. }
  92.  
  93. // update the settings in local storage if there are missing settings
  94. if (missingSetting) {
  95. localStorage.setItem("gallerySettings", JSON.stringify(settings));
  96. }
  97.  
  98. function setStyles(element, styles) {
  99. for (const property in styles) {
  100. element.style[property] = styles[property];
  101. }
  102. }
  103.  
  104. function getPosts(websiteUrl, doc) {
  105. switch (websiteUrl) {
  106. case "warosu.org":
  107. return doc.querySelectorAll(".comment, .highlight");
  108. case "archived.moe":
  109. case "archive.palanq.win":
  110. case "archive.4plebs.org":
  111. case "desuarchive.org":
  112. case "thebarchive.com":
  113. case "archiveofsins.com":
  114. return doc.querySelectorAll(".post, .thread");
  115. case "boards.4chan.org":
  116. case "boards.4channel.org":
  117. default:
  118. return doc.querySelectorAll(".postContainer");
  119. }
  120. }
  121.  
  122. function getDocument(thread, threadURL) {
  123. return new Promise((resolve, reject) => {
  124. if (thread === threadURL) {
  125. resolve(document);
  126. } else {
  127. fetch(thread)
  128. .then((response) => response.text())
  129. .then((html) => {
  130. const parser = new DOMParser();
  131. const doc = parser.parseFromString(html, "text/html");
  132. resolve(doc);
  133. })
  134. .catch((error) => {
  135. reject(error);
  136. });
  137. }
  138. });
  139. }
  140.  
  141. function injectVideoJS() {
  142. const link = document.createElement("link");
  143. link.href = "https://vjs.zencdn.net/8.10.0/video-js.css";
  144. link.rel = "stylesheet";
  145. document.head.appendChild(link);
  146.  
  147. // theme
  148. const theme = document.createElement("link");
  149. theme.href = "https://unpkg.com/@videojs/themes@1/dist/city/index.css";
  150. theme.rel = "stylesheet";
  151. document.head.appendChild(theme);
  152.  
  153. const script = document.createElement("script");
  154. script.src = "https://vjs.zencdn.net/8.10.0/video.min.js";
  155. document.body.appendChild(script);
  156. ("VideoJS injected successfully!");
  157. }
  158.  
  159. function createArrowButton(direction) {
  160. const button = document.createElement('button');
  161. setStyles(button, {
  162. position: 'fixed',
  163. top: '50%',
  164. [direction]: '20px',
  165. transform: 'translateY(-50%)',
  166. zIndex: '10001',
  167. backgroundColor: 'rgba(28, 28, 28, 0.7)',
  168. color: '#d9d9d9',
  169. padding: '15px',
  170. border: 'none',
  171. borderRadius: '50%',
  172. cursor: 'pointer',
  173. display: settings.Show_Arrow_Buttons_In_Zoom_Mode.value ? 'block' : 'none'
  174. });
  175. button.innerHTML = direction === 'left' ? '◀' : '▶';
  176. button.onclick = () => {
  177. const event = new KeyboardEvent('keydown', { key: direction === 'left' ? 'ArrowLeft' : 'ArrowRight' });
  178. document.dispatchEvent(event);
  179. };
  180. return button;
  181. }
  182.  
  183. // Modify createMediaCell to accept mode and postURL parameters
  184. function createMediaCell(url, commentText, mode, postURL, board, threadID, postID) {
  185. if (!gridContainer) {
  186. gridContainer = document.createElement("div");
  187. setStyles(gridContainer, {
  188. display: "grid",
  189. gridTemplateColumns: `repeat(${settings.Grid_Columns.value}, 1fr)`,
  190. gap: "10px",
  191. padding: "20px",
  192. backgroundColor: "#1c1c1c",
  193. color: "#d9d9d9",
  194. maxWidth: "80%",
  195. maxHeight: "80%",
  196. overflowY: "auto",
  197. resize: "both",
  198. overflow: "auto",
  199. border: "1px solid #d9d9d9",
  200. });
  201. }
  202. const cell = document.createElement("div");
  203. setStyles(cell, {
  204. border: "1px solid #d9d9d9",
  205. position: "relative",
  206. });
  207.  
  208. // Make the cell draggable
  209. cell.draggable = true;
  210. cell.addEventListener("dragstart", (e) => {
  211. e.dataTransfer.setData("text/plain", [...gridContainer.children].indexOf(cell));
  212. e.dataTransfer.dropEffect = "move";
  213. });
  214.  
  215. // Allow drops on this cell
  216. cell.addEventListener("dragover", (e) => {
  217. e.preventDefault();
  218. e.dataTransfer.dropEffect = "move";
  219. });
  220.  
  221. cell.addEventListener("drop", (e) => {
  222. e.preventDefault();
  223. const draggedIndex = e.dataTransfer.getData("text/plain");
  224. const containerChildren = [...gridContainer.children];
  225. const draggedCell = containerChildren[draggedIndex];
  226. if (draggedCell !== cell) {
  227. const dropIndex = containerChildren.indexOf(cell);
  228. if (draggedIndex < dropIndex) {
  229. gridContainer.insertBefore(draggedCell, containerChildren[dropIndex].nextSibling);
  230. } else {
  231. gridContainer.insertBefore(draggedCell, containerChildren[dropIndex]);
  232. }
  233. }
  234. });
  235.  
  236. const mediaContainer = document.createElement("div");
  237. setStyles(mediaContainer, {
  238. position: "relative",
  239. display: "flex",
  240. justifyContent: "center",
  241. alignItems: "center",
  242. });
  243.  
  244. const buttonDiv = document.createElement("div");
  245. setStyles(buttonDiv, {
  246. display: "flex",
  247. justifyContent: "space-between",
  248. alignItems: "center",
  249. padding: "5px",
  250. });
  251.  
  252. // Add view post button for external media
  253. const viewPostButton = document.createElement("button");
  254. viewPostButton.textContent = "View Original";
  255. setStyles(viewPostButton, {
  256. backgroundColor: "#1c1c1c",
  257. color: "#d9d9d9",
  258. padding: "5px 10px",
  259. borderRadius: "3px",
  260. border: "none",
  261. cursor: "pointer",
  262. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  263. });
  264. viewPostButton.addEventListener("click", () => {
  265. window.location.href = postURL;
  266. gallerySize = {
  267. width: gridContainer.offsetWidth,
  268. height: gridContainer.offsetHeight,
  269. };
  270. });
  271. buttonDiv.appendChild(viewPostButton);
  272.  
  273. if (url.match(/\.(webm|mp4)$/i)) {
  274. const video = document.createElement("video");
  275. video.src = url;
  276. video.controls = true;
  277. video.title = commentText;
  278. video.setAttribute("fileName", url.split('/').pop());
  279. video.setAttribute("board", board);
  280. video.setAttribute("threadID", threadID);
  281. video.setAttribute("postID", postID);
  282. setStyles(video, {
  283. maxWidth: "100%",
  284. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  285. objectFit: "contain",
  286. cursor: "pointer",
  287. });
  288. mediaContainer.appendChild(video);
  289.  
  290. const openInNewTabButton = document.createElement("button");
  291. openInNewTabButton.textContent = "Open";
  292. setStyles(openInNewTabButton, {
  293. backgroundColor: "#1c1c1c",
  294. color: "#d9d9d9",
  295. padding: "5px 10px",
  296. borderRadius: "3px",
  297. border: "none",
  298. cursor: "pointer",
  299. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  300. });
  301. openInNewTabButton.onclick = () => {
  302. window.open(url, "_blank");
  303. };
  304. buttonDiv.appendChild(openInNewTabButton);
  305. } else if (url.match(/\.(jpg|jpeg|png|gif)$/i)) {
  306. // Only create image cell if mode is "all"
  307. if (mode === "all") {
  308. const image = document.createElement("img");
  309. image.src = url;
  310. image.title = commentText;
  311. image.setAttribute("fileName", url.split('/').pop());
  312. image.setAttribute("actualSrc", url);
  313. image.setAttribute("thumbnailUrl", url);
  314. image.setAttribute("board", board);
  315. image.setAttribute("threadID", threadID);
  316. image.setAttribute("postID", postID);
  317. setStyles(image, {
  318. maxWidth: "100%",
  319. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  320. objectFit: "contain",
  321. cursor: "pointer",
  322. });
  323. image.loading = "lazy";
  324.  
  325. if (
  326. settings.Strictly_Load_GIFs_As_Thumbnails_On_Hover.value &&
  327. url.match(/\.gif$/i)
  328. ) {
  329. image.src = url;
  330. image.addEventListener("mouseover", () => {
  331. image.src = url;
  332. });
  333. image.addEventListener("mouseout", () => {
  334. image.src = url;
  335. });
  336. }
  337.  
  338. mediaContainer.appendChild(image);
  339. } else {
  340. return; // Skip non-webm/soundpost media in webm mode
  341. }
  342. }
  343.  
  344. cell.appendChild(mediaContainer);
  345. cell.appendChild(buttonDiv);
  346. gridContainer.appendChild(cell);
  347. }
  348.  
  349. const loadButton = () => {
  350. const isArchivePage = window.location.pathname.includes("/archive");
  351. let addFakeImage = settings.Add_Placeholder_Image_For_Zoom_Mode.value;
  352.  
  353. const button = document.createElement("button");
  354. button.textContent = "Open Image Gallery";
  355. button.id = "openImageGallery";
  356. setStyles(button, {
  357. position: "fixed",
  358. bottom: "20px",
  359. right: "20px",
  360. zIndex: "1000",
  361. backgroundColor: "#1c1c1c",
  362. color: "#d9d9d9",
  363. padding: "10px 20px",
  364. borderRadius: "5px",
  365. border: "none",
  366. cursor: "pointer",
  367. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  368. visibility: settings.Hide_Gallery_Button.value ? "hidden" : "visible",
  369. });
  370.  
  371. const openImageGallery = () => {
  372. // new check to see if gallery is already in the DOM
  373. const existingGallery = document.getElementById("imageGallery");
  374. if (existingGallery) {
  375. existingGallery.style.display = "flex";
  376. return;
  377. }
  378. addFakeImage = settings.Add_Placeholder_Image_For_Zoom_Mode.value;
  379.  
  380. const gallery = document.createElement("div");
  381. gallery.id = "imageGallery";
  382. setStyles(gallery, {
  383. position: "fixed",
  384. top: "0",
  385. left: "0",
  386. width: "100%",
  387. height: "100%",
  388. backgroundColor: "rgba(0, 0, 0, 0.8)",
  389. display: "flex",
  390. justifyContent: "center",
  391. alignItems: "center",
  392. zIndex: "9999",
  393. });
  394.  
  395. gridContainer = document.createElement("div");
  396. setStyles(gridContainer, {
  397. display: "grid",
  398. gridTemplateColumns: `repeat(${settings.Grid_Columns.value}, 1fr)`,
  399. gap: "10px",
  400. padding: "20px",
  401. backgroundColor: "#1c1c1c",
  402. color: "#d9d9d9",
  403. maxWidth: "80%",
  404. maxHeight: "80%",
  405. overflowY: "auto",
  406. resize: "both",
  407. overflow: "auto",
  408. border: "1px solid #d9d9d9",
  409. });
  410.  
  411. // Add dragover & drop listeners to the grid container
  412. gridContainer.addEventListener("dragover", (e) => {
  413. e.preventDefault();
  414. });
  415. gridContainer.addEventListener("drop", (e) => {
  416. e.preventDefault();
  417. const draggedIndex = e.dataTransfer.getData("text/plain");
  418. const targetCell = e.target.closest("div[draggable='true']");
  419. if (!targetCell) return;
  420. const containerChildren = [...gridContainer.children];
  421. const dropIndex = containerChildren.indexOf(targetCell);
  422. if (draggedIndex >= 0 && dropIndex >= 0) {
  423. const draggedCell = containerChildren[draggedIndex];
  424. if (draggedIndex < dropIndex) {
  425. gridContainer.insertBefore(draggedCell, containerChildren[dropIndex].nextSibling);
  426. } else {
  427. gridContainer.insertBefore(draggedCell, containerChildren[dropIndex]);
  428. }
  429. }
  430. });
  431.  
  432. // Restore the previous grid container size
  433. if (gallerySize.width > 0 && gallerySize.height > 0) {
  434. gridContainer.style.width = `${gallerySize.width}px`;
  435. gridContainer.style.height = `${gallerySize.height}px`;
  436. }
  437.  
  438. let mode = "all"; // Default mode is "all"
  439. let autoPlayWebms = false; // Default auto play webms without sound is false
  440.  
  441. const mediaTypeButtonContainer = document.createElement("div");
  442. setStyles(mediaTypeButtonContainer, {
  443. position: "absolute",
  444. top: "10px",
  445. left: "10px",
  446. display: "flex",
  447. gap: "10px",
  448. });
  449.  
  450. // Toggle mode button
  451. const toggleModeButton = document.createElement("button");
  452. toggleModeButton.textContent = "Toggle Mode (All)";
  453. setStyles(toggleModeButton, {
  454. backgroundColor: "#1c1c1c",
  455. color: "#d9d9d9",
  456. padding: "10px 20px",
  457. borderRadius: "5px",
  458. border: "none",
  459. cursor: "pointer",
  460. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  461. });
  462. toggleModeButton.addEventListener("click", () => {
  463. mode = mode === "all" ? "webm" : "all";
  464. toggleModeButton.textContent = `Toggle Mode (${mode === "all" ? "All" : "Webm & Images with Sound"})`;
  465. gridContainer.innerHTML = ""; // Clear the grid
  466. loadPosts(mode, addFakeImage); // Reload posts based on the new mode
  467. });
  468.  
  469. // Toggle auto play webms button
  470. const toggleAutoPlayButton = document.createElement("button");
  471. toggleAutoPlayButton.textContent = "Auto Play Webms without Sound";
  472. setStyles(toggleAutoPlayButton, {
  473. backgroundColor: "#1c1c1c",
  474. color: "#d9d9d9",
  475. padding: "10px 20px",
  476. borderRadius: "5px",
  477. border: "none",
  478. cursor: "pointer",
  479. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  480. });
  481. toggleAutoPlayButton.addEventListener("click", () => {
  482. autoPlayWebms = !autoPlayWebms;
  483. toggleAutoPlayButton.textContent = autoPlayWebms
  484. ? "Stop Auto Play Webms"
  485. : "Auto Play Webms without Sound";
  486. gridContainer.innerHTML = ""; // Clear the grid
  487. loadPosts(mode, addFakeImage); // Reload posts based on the new mode and auto play setting
  488. });
  489. mediaTypeButtonContainer.appendChild(toggleModeButton);
  490. mediaTypeButtonContainer.appendChild(toggleAutoPlayButton);
  491. gallery.appendChild(mediaTypeButtonContainer);
  492.  
  493. // settings button on the top right corner of the screen
  494. const settingsButton = document.createElement("button");
  495. settingsButton.id = "settingsButton";
  496. settingsButton.textContent = "Settings";
  497. setStyles(settingsButton, {
  498. position: "absolute",
  499. top: "20px",
  500. right: "20px",
  501. backgroundColor: "#007bff", // Primary color
  502. color: "#fff",
  503. padding: "10px 20px",
  504. borderRadius: "5px",
  505. border: "none",
  506. cursor: "pointer",
  507. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  508. transition: "background-color 0.3s ease",
  509. });
  510. settingsButton.addEventListener("click", () => {
  511. const settingsContainer = document.createElement("div");
  512. settingsContainer.id = "settingsContainer";
  513. setStyles(settingsContainer, {
  514. position: "fixed",
  515. top: "0",
  516. left: "0",
  517. width: "100%",
  518. height: "100%",
  519. backgroundColor: "rgba(0, 0, 0, 0.8)",
  520. display: "flex",
  521. justifyContent: "center",
  522. alignItems: "center",
  523. zIndex: "9999",
  524. animation: "fadeIn 0.3s ease",
  525. });
  526.  
  527. const settingsBox = document.createElement("div");
  528. setStyles(settingsBox, {
  529. backgroundColor: "#000000", // Background color
  530. color: "#ffffff", // Text color
  531. padding: "30px",
  532. borderRadius: "10px",
  533. border: "1px solid #6c757d", // Secondary color
  534. maxWidth: "80%",
  535. maxHeight: "80%",
  536. overflowY: "auto",
  537. boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
  538. });
  539.  
  540. const settingsTitle = document.createElement("h2");
  541. settingsTitle.id = "settingsTitle";
  542. settingsTitle.textContent = "Settings";
  543. setStyles(settingsTitle, {
  544. textAlign: "center",
  545. marginBottom: "20px",
  546. });
  547.  
  548. const settingsList = document.createElement("ul");
  549. settingsList.id = "settingsList";
  550. setStyles(settingsList, {
  551. listStyleType: "none",
  552. padding: "0",
  553. margin: "0",
  554. });
  555.  
  556. // include default settings as existing settings inside the input fields
  557. // have an icon next to the setting that explains what the setting does
  558. for (const setting in settings) {
  559. // remove settings that are not in the default settings
  560. if (!(setting in defaultSettings)) {
  561. delete settings[setting];
  562. continue;
  563. }
  564.  
  565. const settingItem = document.createElement("li");
  566. setStyles(settingItem, {
  567. display: "flex",
  568. alignItems: "center",
  569. marginBottom: "15px",
  570. });
  571.  
  572. const settingLabel = document.createElement("label");
  573. settingLabel.textContent = setting.replace(/_/g, " ");
  574. settingLabel.title = settings[setting].info;
  575. setStyles(settingLabel, {
  576. flex: "1",
  577. display: "flex",
  578. alignItems: "center",
  579. });
  580.  
  581. const settingIcon = document.createElement("span");
  582. settingIcon.className = "material-icons-outlined";
  583. settingIcon.textContent = settings[setting].icon;
  584. settingIcon.style.marginRight = "10px";
  585. settingLabel.prepend(settingIcon);
  586.  
  587. settingItem.appendChild(settingLabel);
  588.  
  589. const settingInput = document.createElement("input");
  590. const settingValueType = typeof defaultSettings[setting].value;
  591. if (settingValueType === "boolean") {
  592. settingInput.type = "checkbox";
  593. settingInput.checked = settings[setting].value;
  594. } else if (settingValueType === "number") {
  595. settingInput.type = "number";
  596. settingInput.value = settings[setting].value;
  597. } else {
  598. settingInput.type = "text";
  599. settingInput.value = settings[setting].value;
  600. }
  601. setStyles(settingInput, {
  602. padding: "8px 12px",
  603. borderRadius: "5px",
  604. border: "1px solid #6c757d", // Secondary color
  605. flex: "2",
  606. });
  607. settingInput.addEventListener("focus", () => {
  608. setStyles(settingInput, {
  609. borderColor: "#007bff", // Primary color
  610. boxShadow: "0 0 0 2px rgba(0, 123, 255, 0.25)",
  611. outline: "none",
  612. });
  613. });
  614. settingInput.addEventListener("blur", () => {
  615. setStyles(settingInput, {
  616. borderColor: "#6c757d", // Secondary color
  617. boxShadow: "none",
  618. });
  619. });
  620.  
  621. if (settingValueType === "boolean") {
  622. settingInput.style.marginRight = "10px";
  623. }
  624.  
  625. settingItem.appendChild(settingInput);
  626. settingsList.appendChild(settingItem);
  627. }
  628.  
  629. const saveButton = document.createElement("button");
  630. saveButton.id = "saveButton";
  631. saveButton.textContent = "Save";
  632. setStyles(saveButton, {
  633. backgroundColor: "#007bff", // Primary color
  634. color: "#fff",
  635. padding: "10px 20px",
  636. borderRadius: "5px",
  637. border: "none",
  638. cursor: "pointer",
  639. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  640. transition: "background-color 0.3s ease",
  641. marginRight: "10px",
  642. });
  643. saveButton.addEventListener("click", () => {
  644. const newSettings = {};
  645. // First copy default settings structure
  646. for (const key in defaultSettings) {
  647. newSettings[key] = { ...defaultSettings[key] };
  648. }
  649.  
  650. const inputs = document.querySelectorAll("#settingsList input");
  651. inputs.forEach((input) => {
  652. const settingName = input.previousSibling.textContent.replace(
  653. / /g,
  654. "_"
  655. );
  656. if (settingName in defaultSettings) {
  657. newSettings[settingName].value = input.type === "checkbox" ? input.checked : input.value;
  658. }
  659. });
  660. localStorage.setItem("gallerySettings", JSON.stringify(newSettings));
  661. settings = newSettings;
  662. settingsContainer.remove();
  663.  
  664. const gallery = document.querySelector('#imageGallery');
  665. if (gallery) {
  666. document.body.removeChild(gallery);
  667. setTimeout(() => {
  668. document.querySelector('#openImageGallery').click();
  669. }, 20);
  670. }
  671. });
  672.  
  673. // Close button
  674. const closeButton = document.createElement("button");
  675. closeButton.id = "closeButton";
  676. closeButton.textContent = "Close";
  677. setStyles(closeButton, {
  678. backgroundColor: "#007bff", // Primary color
  679. color: "#fff",
  680. padding: "10px 20px",
  681. borderRadius: "5px",
  682. border: "none",
  683. cursor: "pointer",
  684. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  685. transition: "background-color 0.3s ease",
  686. });
  687. closeButton.addEventListener("click", () => {
  688. settingsContainer.remove();
  689. });
  690.  
  691. settingsBox.appendChild(settingsTitle);
  692. settingsBox.appendChild(settingsList);
  693. settingsBox.appendChild(saveButton);
  694. settingsBox.appendChild(closeButton);
  695. settingsContainer.appendChild(settingsBox);
  696. gallery.appendChild(settingsContainer);
  697. });
  698.  
  699. // Hover effect for settings button
  700. settingsButton.addEventListener("mouseenter", () => {
  701. settingsButton.style.backgroundColor = "#0056b3";
  702. });
  703. settingsButton.addEventListener("mouseleave", () => {
  704. settingsButton.style.backgroundColor = "#007bff";
  705. });
  706.  
  707. gallery.appendChild(settingsButton);
  708.  
  709. const loadPosts = (mode, addFakeImage) => {
  710. const checkedThreads = isArchivePage
  711. ? // Get all checked threads in the archive page or the current link if it's not an archive page
  712. Array.from(
  713. document.querySelectorAll(
  714. ".flashListing input[type='checkbox']:checked"
  715. )
  716. ).map((checkbox) => {
  717. let archiveSite =
  718. checkbox.parentNode.parentNode.querySelector("a").href;
  719. return archiveSite;
  720. })
  721. : [threadURL];
  722.  
  723. const loadPostsFromThread = (thread, addFakeImage) => {
  724. // get the website url without the protocol and next slash
  725. let websiteUrl = thread.replace(/(^\w+:|^)\/\//, "").split("/")[0];
  726.  
  727. // const board = thread.split("/thread/")[0].split("/").pop();
  728. // const threadNo = `${parseInt(thread.split("thread/").pop())}`
  729. getDocument(thread, threadURL).then((doc) => {
  730. let posts;
  731.  
  732. // use a case statement to deal with different websites
  733. posts = getPosts(websiteUrl, doc);
  734.  
  735. // add thread and website url as attributes to every post
  736. posts.forEach((post) => {
  737. post.setAttribute("thread", thread);
  738. post.setAttribute("websiteUrl", websiteUrl);
  739. });
  740.  
  741. if (addFakeImage) {
  742. // Add a fake image to the grid container to allow zoom mode to open even if the thread has no images
  743. let placeholder_imageURL = "https://files.pixstash.moe/ecl8vh.png";
  744. let examplePost = document.createElement("div");
  745. examplePost.innerHTML = `
  746. <div class="postContainer", id="1231232">
  747. <div class="fileText">
  748. <a href="${placeholder_imageURL}" download="${placeholder_imageURL}">OpenZoomMode[sound=https://files.catbox.moe/brugtt.mp3].jpg</a>
  749. </div>
  750. <div class="fileThumb">
  751. <img src="${placeholder_imageURL}" alt="Thumbnail">
  752. </div>
  753. <div class="postMessage">
  754. Just a placeholder image for zoom mode
  755. </div>
  756. </div>
  757. `;
  758. examplePost.setAttribute("thread", "https://boards.4chan.org/b/thread/123456789");
  759. examplePost.setAttribute("websiteUrl", "boards.4chan.org");
  760. posts = [examplePost, ...posts];
  761. }
  762.  
  763. posts.forEach((post) => {
  764. let mediaLinkFlag = false;
  765. let board;
  766. let threadID;
  767. let postID;
  768. let postURL;
  769. let thumbnailUrl;
  770. let mediaLink;
  771. let fileName;
  772. let comment;
  773.  
  774. let isVideo;
  775. let isImage;
  776. let soundLink;
  777. let encodedSoundPostLink;
  778. let temp;
  779. let hasEmbeddedMediaLink = false;
  780. let matches;
  781.  
  782. websiteUrl = post.getAttribute("websiteUrl");
  783. thread = post.getAttribute("thread");
  784.  
  785. // case statement for different websites
  786. switch (websiteUrl) {
  787. case "warosu.org":
  788. let thumbnailElement = post.querySelector(".thumb");
  789.  
  790. fileName = post
  791. .querySelector(".fileinfo")
  792. ?.innerText.split(", ")[2];
  793. thumbnailUrl = thumbnailElement?.src;
  794. mediaLink = thumbnailElement?.parentNode.href;
  795. comment = post.querySelector("blockquote");
  796.  
  797. threadID = post.getAttribute("thread").match(/thread\/(\d+)/)
  798. if (threadID) {
  799. threadID = threadID[1];
  800. } else {
  801. threadID = post.querySelector(".js").href.match(/thread\/(\d+)/)[1];
  802. }
  803.  
  804. postID = post.id.replace("pc", "").replace("p", "");
  805. break;
  806. case "archived.moe":
  807. case "archive.palanq.win":
  808. case "archive.4plebs.org":
  809. case "desuarchive.org":
  810. case "thebarchive.com":
  811. case "archiveofsins.com":
  812. thumbnailUrl = post.querySelector(".post_image")?.src;
  813. mediaLink = post.querySelector(".thread_image_link")?.href;
  814. fileName = post.querySelector(
  815. ".post_file_filename"
  816. )?.title;
  817. comment = post.querySelector(".text");
  818. threadID = post.querySelector(".post_data > a")?.href.match(
  819. /thread\/(\d+)/
  820. )[1];
  821. postID = post.id
  822. break;
  823. case "boards.4chan.org":
  824. case "boards.4channel.org":
  825. default:
  826. if (post.querySelector(".fileText")) {
  827. // if they have 4chanX installed, there will be a fileText-orignal class
  828. if (post.querySelector(".download-button")) {
  829. temp = post.querySelector(".download-button");
  830. mediaLink = temp.href;
  831. fileName = temp.download;
  832. } else {
  833. if (post.classList.contains("opContainer")) {
  834. mediaLink = post.querySelector(".fileText a");
  835. temp = mediaLink;
  836. } else {
  837. mediaLink = post.querySelector(".fileText");
  838. temp = mediaLink.querySelector("a");
  839. }
  840. if (mediaLink.title === "") {
  841. if (temp.title === "") {
  842. fileName = temp.innerText;
  843. } else {
  844. fileName = temp.title;
  845. }
  846. } else {
  847. fileName = mediaLink.title;
  848. }
  849. mediaLink = temp.href;
  850. }
  851. thumbnailUrl = post.querySelector(".fileThumb img")?.src;
  852. }
  853.  
  854. comment = post.querySelector(".postMessage");
  855.  
  856. threadID = thread.match(/thread\/(\d+)/)[1];
  857. postID = post.id.replace("pc", "").replace("p", "");
  858. }
  859.  
  860. const fileExtRegex = /\.(webm|mp4|jpg|png|gif)$/i;
  861. const linkRegex = /https:\/\/(files|litter)\.(catbox|pixstash)\.moe\/[a-z0-9]+\.(jpg|png|gif|webm|mp4)/g;
  862.  
  863. if (mediaLink) {
  864. const ext = mediaLink.match(fileExtRegex)?.[1]?.toLowerCase();
  865. isVideo = ext === 'webm' || ext === 'mp4';
  866. isImage = ext === 'jpg' || ext === 'png' || ext === 'gif';
  867. soundLink = fileName.match(/\[sound=(.+?)\]/);
  868. mediaLinkFlag = true;
  869. }
  870. if (settings.Embed_External_Links.value && comment) {
  871. matches = Array.from(comment.innerText.matchAll(linkRegex)).map(match => match[0]);
  872. if (matches.length > 0) {
  873. if (!mediaLinkFlag) {
  874. mediaLink = matches[0];
  875. fileName = mediaLink.split("/").pop();
  876. thumbnailUrl = mediaLink;
  877.  
  878. if (hasEmbeddedMediaLink) {
  879. matches.shift();
  880. }
  881.  
  882. const ext = mediaLink.match(fileExtRegex)?.[1]?.toLowerCase();
  883. isVideo = ext === 'webm' || ext === 'mp4';
  884. isImage = ext === 'jpg' || ext === 'png' || ext === 'gif';
  885. soundLink = fileName.match(/\[sound=(.+?)\]/);
  886. mediaLinkFlag = true;
  887. }
  888. hasEmbeddedMediaLink = matches.length > 0;
  889. }
  890. }
  891.  
  892. // replace the "#pcXXXXXXX" or "#pXXXXXXX" with an empty string to get the actual thread url
  893. if (thread.includes("#")) {
  894. postURL = thread.replace(/#p\d+/, "");
  895. postURL = postURL.replace(/#pc\d+/, "");
  896. } else {
  897. postURL = thread;
  898. }
  899.  
  900. // post info (constant)
  901. board = thread.match(/\/\/[^\/]+\/([^\/]+)/)[1];
  902. if (soundLink) {
  903. encodedSoundPostLink = `https://4chan.mahdeensky.top/${board}/thread/${threadID}/${postID}`;
  904. }
  905.  
  906. if (mediaLinkFlag) {
  907. // Check if the post should be loaded based on the mode
  908. if (
  909. mode === "all" ||
  910. (mode === "webm" && (isVideo || (isImage && soundLink)))
  911. ) {
  912. // Insert a button/link to open media in new tab for videos
  913. const cell = document.createElement("div");
  914. setStyles(cell, {
  915. border: "1px solid #d9d9d9",
  916. position: "relative",
  917. });
  918.  
  919. // Make the cell draggable
  920. cell.draggable = true;
  921. cell.addEventListener("dragstart", (e) => {
  922. e.dataTransfer.setData("text/plain", [...gridContainer.children].indexOf(cell));
  923. e.dataTransfer.dropEffect = "move";
  924. });
  925.  
  926. // Allow drops on this cell
  927. cell.addEventListener("dragover", (e) => {
  928. e.preventDefault();
  929. e.dataTransfer.dropEffect = "move";
  930. });
  931.  
  932. cell.addEventListener("drop", (e) => {
  933. e.preventDefault();
  934. const draggedIndex = e.dataTransfer.getData("text/plain");
  935. const containerChildren = [...gridContainer.children];
  936. const draggedCell = containerChildren[draggedIndex];
  937. if (draggedCell !== cell) {
  938. const dropIndex = containerChildren.indexOf(cell);
  939. if (draggedIndex < dropIndex) {
  940. gridContainer.insertBefore(draggedCell, containerChildren[dropIndex].nextSibling);
  941. } else {
  942. gridContainer.insertBefore(draggedCell, containerChildren[dropIndex]);
  943. }
  944. }
  945. });
  946.  
  947. const buttonDiv = document.createElement("div");
  948. setStyles(buttonDiv, {
  949. display: "flex",
  950. justifyContent: "space-between",
  951. alignItems: "center",
  952. padding: "5px",
  953. });
  954.  
  955. if (isVideo) {
  956. const videoContainer = document.createElement("div");
  957. setStyles(videoContainer, {
  958. position: "relative",
  959. display: "flex",
  960. justifyContent: "center",
  961. });
  962.  
  963. // if medialink is catbox.moe or pixstash.moe, then video thumbnail is a video element with no controls
  964. let videoThumbnail;
  965. if (mediaLink.match(/catbox.moe|pixstash.moe/)) {
  966. videoThumbnail = document.createElement("video");
  967. } else {
  968. videoThumbnail = document.createElement("img");
  969. }
  970. videoThumbnail.src = thumbnailUrl;
  971. videoThumbnail.alt = "Video Thumbnail";
  972. setStyles(videoThumbnail, {
  973. width: "100%",
  974. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  975. objectFit: "contain",
  976. cursor: "pointer",
  977. });
  978. videoThumbnail.loading = "lazy";
  979.  
  980. const video = document.createElement("video");
  981. video.src = mediaLink;
  982. video.controls = true;
  983. video.title = comment.innerText;
  984. video.videothumbnailDisplayed = "true";
  985. video.setAttribute("fileName", fileName);
  986. video.setAttribute("board", board);
  987. video.setAttribute("threadID", threadID);
  988. video.setAttribute("postID", postID);
  989. setStyles(video, {
  990. maxWidth: "100%",
  991. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  992. objectFit: "contain",
  993. cursor: "pointer",
  994. display: "none",
  995. });
  996.  
  997. // videoJS stuff (not working for some reason)
  998. // video.className = "video-js";
  999. // video.setAttribute("data-setup", "{}");
  1000. // const source = document.createElement("source");
  1001. // source.src = mediaLink;
  1002. // source.type = "video/webm";
  1003. // video.appendChild(source);
  1004.  
  1005. videoThumbnail.addEventListener("click", () => {
  1006. videoThumbnail.style.display = "none";
  1007. video.style.display = "block";
  1008. video.videothumbnailDisplayed = "false";
  1009. // video.load();
  1010. });
  1011.  
  1012. // hide the video thumbnail and show the video when hovered
  1013. videoThumbnail.addEventListener("mouseenter", () => {
  1014. videoThumbnail.style.display = "none";
  1015. video.style.display = "block";
  1016. video.videothumbnailDisplayed = "false";
  1017. // video.load();
  1018. });
  1019.  
  1020. // Play webms without sound automatically on hover or if autoPlayWebms is true
  1021. if (!soundLink) {
  1022. if (autoPlayWebms) {
  1023. video.addEventListener("canplaythrough", () => {
  1024. video.play();
  1025. video.loop = true; // Loop webms when autoPlayWebms is true
  1026. });
  1027. } else {
  1028. if (settings.Play_Webms_On_Hover.value) {
  1029. video.addEventListener("mouseenter", () => {
  1030. video.play();
  1031. });
  1032. video.addEventListener("mouseleave", () => {
  1033. video.pause();
  1034. });
  1035. }
  1036. }
  1037. }
  1038.  
  1039. videoContainer.appendChild(videoThumbnail);
  1040. videoContainer.appendChild(video);
  1041.  
  1042. if (soundLink) {
  1043. // video.preload = "none"; // Disable video preload for better performance
  1044.  
  1045. const audio = document.createElement("audio");
  1046. audio.src = decodeURIComponent(
  1047. soundLink[1].startsWith("http")
  1048. ? soundLink[1]
  1049. : `https://${soundLink[1]}`
  1050. );
  1051.  
  1052. // if switch catbox to pixstash is enabled, replace catbox.moe with pixstash.moe
  1053. if (settings.Switch_Catbox_To_Pixstash_For_Soundposts.value) {
  1054. audio.src = audio.src.replace("catbox.moe", "pixstash.moe");
  1055. }
  1056.  
  1057. // add attribute to the audio element with the encoded soundpost link
  1058. audio.setAttribute(
  1059. "encodedSoundPostLink",
  1060. encodedSoundPostLink
  1061. );
  1062. videoContainer.appendChild(audio);
  1063.  
  1064. const resetButton = document.createElement("button");
  1065. resetButton.textContent = "Reset";
  1066. setStyles(resetButton, {
  1067. backgroundColor: "#1c1c1c",
  1068. color: "#d9d9d9",
  1069. padding: "5px 10px",
  1070. borderRadius: "3px",
  1071. border: "none",
  1072. cursor: "pointer",
  1073. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1074. });
  1075. resetButton.addEventListener("click", () => {
  1076. video.currentTime = 0;
  1077. audio.currentTime = 0;
  1078. });
  1079. buttonDiv.appendChild(resetButton);
  1080.  
  1081. // html5 video play
  1082. video.onplay = (event) => {
  1083. audio.play();
  1084. };
  1085.  
  1086. video.onpause = (event) => {
  1087. audio.pause();
  1088. };
  1089.  
  1090. let lastVideoTime = 0;
  1091. // Sync audio with video on timeupdate event only if the difference is 2 seconds or more
  1092. video.addEventListener("timeupdate", () => {
  1093. if (Math.abs(video.currentTime - lastVideoTime) >= 2) {
  1094. audio.currentTime = video.currentTime;
  1095. lastVideoTime = video.currentTime;
  1096. }
  1097. lastVideoTime = video.currentTime;
  1098. });
  1099. }
  1100.  
  1101. cell.appendChild(videoContainer);
  1102. } else if (isImage) {
  1103. const imageContainer = document.createElement("div");
  1104. setStyles(imageContainer, {
  1105. position: "relative",
  1106. display: "flex",
  1107. justifyContent: "center",
  1108. alignItems: "center",
  1109. });
  1110.  
  1111. const image = document.createElement("img");
  1112. image.src = thumbnailUrl;
  1113. if (settings.Load_High_Res_Images_By_Default.value) {
  1114. image.src = mediaLink;
  1115. }
  1116. if (mediaLink.includes(".gif")) {
  1117. image.src = mediaLink;
  1118.  
  1119. if (
  1120. settings.Strictly_Load_GIFs_As_Thumbnails_On_Hover.value
  1121. ) {
  1122. mediaLink = thumbnailUrl;
  1123. image.src = thumbnailUrl;
  1124. }
  1125.  
  1126. }
  1127. image.setAttribute("fileName", fileName);
  1128. image.setAttribute("actualSrc", mediaLink);
  1129. image.setAttribute("thumbnailUrl", thumbnailUrl);
  1130. image.setAttribute("board", board);
  1131. image.setAttribute("threadID", threadID);
  1132. image.setAttribute("postID", postID);
  1133. setStyles(image, {
  1134. maxWidth: "100%",
  1135. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  1136. objectFit: "contain",
  1137. cursor: "pointer",
  1138. });
  1139.  
  1140. let createDarkenBackground = () => {
  1141. const background = document.createElement("div");
  1142. background.id = "darkenBackground";
  1143. setStyles(background, {
  1144. position: "fixed",
  1145. top: "0",
  1146. left: "0",
  1147. width: "100%",
  1148. height: "100%",
  1149. backgroundColor: "rgba(0, 0, 0, 0.3)",
  1150. backdropFilter: "blur(5px)",
  1151. zIndex: "9999",
  1152. });
  1153. return background;
  1154. };
  1155.  
  1156. let zoomImage = () => {
  1157. // have the image pop up centered in front of the screen so that it fills about 80% of the screen
  1158. image.style = "";
  1159. image.src = mediaLink;
  1160. setStyles(image, {
  1161. position: "fixed",
  1162. top: "50%",
  1163. left: "50%",
  1164. transform: "translate(-50%, -50%)",
  1165. zIndex: "10000",
  1166. height: "80%",
  1167. width: "80%",
  1168. objectFit: "contain",
  1169. cursor: "pointer",
  1170. });
  1171.  
  1172. // darken and blur the background behind the image without affecting the image
  1173. const background = createDarkenBackground();
  1174. background.appendChild(createArrowButton('left'));
  1175. background.appendChild(createArrowButton('right'));
  1176. gallery.appendChild(background);
  1177.  
  1178. // create a container for the buttons, number, and download buttons (even space between them)
  1179. // position: fixed; bottom: 10px; display: flex; flex-direction: row; justify-content: space-around; z-index: 10000; width: 100%; margin:auto;
  1180. const bottomContainer = document.createElement("div");
  1181. setStyles(bottomContainer, {
  1182. position: "fixed",
  1183. bottom: "10px",
  1184. display: "flex",
  1185. flexDirection: "row",
  1186. justifyContent: "space-around",
  1187. zIndex: "10000",
  1188. width: "100%",
  1189. margin: "auto",
  1190. });
  1191. background.appendChild(bottomContainer);
  1192.  
  1193. // buttons on the bottom left of the screen for reverse image search (SauceNAO, Google Lens, Yandex)
  1194. const buttonContainer = document.createElement("div");
  1195. setStyles(buttonContainer, {
  1196. display: "flex",
  1197. gap: "10px",
  1198. });
  1199. buttonContainer.setAttribute("mediaLink", mediaLink);
  1200.  
  1201. const sauceNAOButton = document.createElement("button");
  1202. sauceNAOButton.textContent = "SauceNAO";
  1203. setStyles(sauceNAOButton, {
  1204. backgroundColor: "#1c1c1c",
  1205. color: "#d9d9d9",
  1206. padding: "5px 10px",
  1207. borderRadius: "3px",
  1208. border: "none",
  1209. cursor: "pointer",
  1210. });
  1211. sauceNAOButton.addEventListener("click", () => {
  1212. window.open(
  1213. `https://saucenao.com/search.php?url=${encodeURIComponent(
  1214. buttonContainer.getAttribute("mediaLink")
  1215. )}`
  1216. );
  1217. });
  1218. buttonContainer.appendChild(sauceNAOButton);
  1219.  
  1220. const googleLensButton = document.createElement("button");
  1221. googleLensButton.textContent = "Google Lens";
  1222. setStyles(googleLensButton, {
  1223. backgroundColor: "#1c1c1c",
  1224. color: "#d9d9d9",
  1225. padding: "5px 10px",
  1226. borderRadius: "3px",
  1227. border: "none",
  1228. cursor: "pointer",
  1229. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1230. });
  1231. googleLensButton.addEventListener("click", () => {
  1232. window.open(
  1233. `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(
  1234. buttonContainer.getAttribute("mediaLink")
  1235. )}`
  1236. );
  1237. });
  1238. buttonContainer.appendChild(googleLensButton);
  1239.  
  1240. const yandexButton = document.createElement("button");
  1241. yandexButton.textContent = "Yandex";
  1242. setStyles(yandexButton, {
  1243. backgroundColor: "#1c1c1c",
  1244. color: "#d9d9d9",
  1245. padding: "5px 10px",
  1246. borderRadius: "3px",
  1247. border: "none",
  1248. cursor: "pointer",
  1249. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1250. });
  1251. yandexButton.addEventListener("click", () => {
  1252. window.open(
  1253. `https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(
  1254. buttonContainer.getAttribute("mediaLink")
  1255. )}`
  1256. );
  1257. });
  1258. buttonContainer.appendChild(yandexButton);
  1259.  
  1260. bottomContainer.appendChild(buttonContainer);
  1261.  
  1262. // download container for video/img and audio
  1263. const downloadButtonContainer =
  1264. document.createElement("div");
  1265. setStyles(downloadButtonContainer, {
  1266. display: "flex",
  1267. gap: "10px",
  1268. });
  1269. bottomContainer.appendChild(downloadButtonContainer);
  1270.  
  1271. const viewPostButton = document.createElement("a");
  1272. viewPostButton.textContent = "View Post";
  1273. viewPostButton.href = `https://boards.4chan.org/${board}/thread/${threadID}#p${postID}`;
  1274. setStyles(viewPostButton, {
  1275. backgroundColor: "#1c1c1c",
  1276. color: "#d9d9d9",
  1277. padding: "5px 10px",
  1278. borderRadius: "3px",
  1279. border: "none",
  1280. cursor: "pointer",
  1281. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1282. });
  1283. downloadButtonContainer.appendChild(viewPostButton);
  1284.  
  1285. const downloadButton = document.createElement("a");
  1286. downloadButton.textContent = "Download Video/Image";
  1287. downloadButton.href = mediaLink;
  1288. downloadButton.download = fileName;
  1289. downloadButton.target = "_blank";
  1290. setStyles(downloadButton, {
  1291. backgroundColor: "#1c1c1c",
  1292. color: "#d9d9d9",
  1293. padding: "5px 10px",
  1294. borderRadius: "3px",
  1295. border: "none",
  1296. cursor: "pointer",
  1297. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1298. });
  1299. downloadButtonContainer.appendChild(downloadButton);
  1300.  
  1301. const audioDownloadButton = document.createElement("a");
  1302. audioDownloadButton.textContent = "Download Audio";
  1303. audioDownloadButton.target = "_blank";
  1304. setStyles(audioDownloadButton, {
  1305. backgroundColor: "#1c1c1c",
  1306. color: "#d9d9d9",
  1307. padding: "5px 10px",
  1308. borderRadius: "3px",
  1309. border: "none",
  1310. cursor: "pointer",
  1311. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1312. });
  1313. if (soundLink) {
  1314. audioDownloadButton.href = decodeURIComponent(
  1315. soundLink[1].startsWith("http")
  1316. ? soundLink[1]
  1317. : `https://${soundLink[1]}`
  1318. );
  1319.  
  1320. // if switch catbox to pixstash is enabled, replace catbox.moe with pixstash.moe
  1321. if (settings.Switch_Catbox_To_Pixstash_For_Soundposts.value) {
  1322. audioDownloadButton.href = audioDownloadButton.href.replace(
  1323. "catbox.moe",
  1324. "pixstash.moe"
  1325. );
  1326. }
  1327.  
  1328. audioDownloadButton.download = soundLink[1]
  1329. .split("/")
  1330. .pop();
  1331. } else {
  1332. audioDownloadButton.style.display = "none";
  1333. }
  1334. downloadButtonContainer.appendChild(audioDownloadButton);
  1335.  
  1336. // a button beside the download video and download audio button that says download encoded soundpost which links to the following url in a new tab "https://4chan.mahdeensky.top/<board>/thread/<thread>/<post>" where things between the <>, are variables to be replaced
  1337. const encodedSoundPostButton =
  1338. document.createElement("a");
  1339. encodedSoundPostButton.textContent =
  1340. "Download Encoded Soundpost";
  1341. encodedSoundPostButton.target = "_blank";
  1342. setStyles(encodedSoundPostButton, {
  1343. backgroundColor: "#1c1c1c",
  1344. color: "#d9d9d9",
  1345. padding: "5px 10px",
  1346. borderRadius: "3px",
  1347. border: "none",
  1348. cursor: "pointer",
  1349. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1350. });
  1351. if (soundLink) {
  1352. encodedSoundPostButton.href = `https://4chan.mahdeensky.top/${board}/thread/${threadID}/${postID}`;
  1353. } else {
  1354. encodedSoundPostButton.style.display = "none";
  1355. }
  1356. downloadButtonContainer.appendChild(
  1357. encodedSoundPostButton
  1358. );
  1359.  
  1360. // number on the bottom right of the screen to show which image is currently being viewed
  1361. const imageNumber = document.createElement("div");
  1362. let currentImageNumber =
  1363. Array.from(cell.parentNode.children).indexOf(cell) + 1;
  1364. let imageTotal = cell.parentNode.children.length;
  1365. imageNumber.textContent = `${currentImageNumber}/${imageTotal}`;
  1366. setStyles(imageNumber, {
  1367. backgroundColor: "#1c1c1c",
  1368. color: "#d9d9d9",
  1369. padding: "5px 10px",
  1370. borderRadius: "3px",
  1371. border: "none",
  1372. cursor: "pointer",
  1373. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1374. position: "fixed",
  1375. top: "10px",
  1376. left: "10px",
  1377. });
  1378. background.appendChild(imageNumber);
  1379.  
  1380. // title of the image/video on the top left of the screen
  1381. const imageTitle = document.createElement("div");
  1382. imageTitle.textContent = fileName;
  1383. setStyles(imageTitle, {
  1384. position: "fixed",
  1385. top: "10px",
  1386. right: "10px",
  1387. backgroundColor: "#1c1c1c",
  1388. color: "#d9d9d9",
  1389. padding: "5px 10px",
  1390. borderRadius: "3px",
  1391. border: "none",
  1392. cursor: "pointer",
  1393. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1394. zIndex: "10000",
  1395. });
  1396. background.appendChild(imageTitle);
  1397.  
  1398. let currentCell = cell;
  1399.  
  1400. function navigateImage(direction) {
  1401. const targetCell = direction === 'left' ? currentCell.previousElementSibling : currentCell.nextElementSibling;
  1402. if (!targetCell) return;
  1403.  
  1404. // ...existing navigation code using targetCell instead of previousCell/nextCell...
  1405. if (gallery.querySelector("#zoomedVideo")) {
  1406. if (
  1407. gallery
  1408. .querySelector("#zoomedVideo")
  1409. .querySelector("audio")
  1410. ) {
  1411. gallery
  1412. .querySelector("#zoomedVideo")
  1413. .querySelector("audio")
  1414. .pause();
  1415. }
  1416. gallery.removeChild(
  1417. gallery.querySelector("#zoomedVideo")
  1418. );
  1419. } else if (gallery.querySelector("#zoomedImage")) {
  1420. gallery.removeChild(
  1421. gallery.querySelector("#zoomedImage")
  1422. );
  1423. } else {
  1424. image.style = "";
  1425. // image.src = thumbnailUrl;
  1426. setStyles(image, {
  1427. maxWidth: "100%",
  1428. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  1429. objectFit: "contain",
  1430. });
  1431. }
  1432.  
  1433. // check if it has a video
  1434. const video = targetCell?.querySelector("video");
  1435. if (video) {
  1436. const video = targetCell
  1437. .querySelector("video")
  1438. .cloneNode(true);
  1439. video.id = "zoomedVideo";
  1440. video.style = "";
  1441. setStyles(video, {
  1442. position: "fixed",
  1443. top: "50%",
  1444. left: "50%",
  1445. transform: "translate(-50%, -50%)",
  1446. zIndex: "10000",
  1447. height: "80%",
  1448. width: "80%",
  1449. objectFit: "contain",
  1450. cursor: "pointer",
  1451. preload: "auto",
  1452. });
  1453. gallery.appendChild(video);
  1454.  
  1455. // check if there is an audio element
  1456. let audio = targetCell.querySelector("audio");
  1457. if (audio) {
  1458. audio = audio.cloneNode(true);
  1459.  
  1460. // same event listeners as the video
  1461. video.onplay = (event) => {
  1462. audio.play();
  1463. };
  1464.  
  1465. video.onpause = (event) => {
  1466. audio.pause();
  1467. };
  1468.  
  1469. let lastVideoTime = 0;
  1470. video.addEventListener("timeupdate", () => {
  1471. if (
  1472. Math.abs(
  1473. video.currentTime - lastVideoTime
  1474. ) >= 2
  1475. ) {
  1476. audio.currentTime = video.currentTime;
  1477. lastVideoTime = video.currentTime;
  1478. }
  1479. lastVideoTime = video.currentTime;
  1480. });
  1481. video.appendChild(audio);
  1482. }
  1483. } else {
  1484. // if it doesn't have a video, it must have an image
  1485. const originalImage =
  1486. targetCell.querySelector("img");
  1487. const currentImage =
  1488. originalImage.cloneNode(true);
  1489. currentImage.id = "zoomedImage";
  1490. currentImage.style = "";
  1491. currentImage.src =
  1492. currentImage.getAttribute("actualSrc");
  1493. originalImage.src =
  1494. originalImage.getAttribute("actualSrc");
  1495. setStyles(currentImage, {
  1496. position: "fixed",
  1497. top: "50%",
  1498. left: "50%",
  1499. transform: "translate(-50%, -50%)",
  1500. zIndex: "10000",
  1501. height: "80%",
  1502. width: "80%",
  1503. objectFit: "contain",
  1504. cursor: "pointer",
  1505. });
  1506. gallery.appendChild(currentImage);
  1507. currentImage.addEventListener("click", () => {
  1508. gallery.removeChild(currentImage);
  1509. gallery.removeChild(background);
  1510. document.removeEventListener(
  1511. "keydown",
  1512. keybindHandler
  1513. );
  1514. });
  1515.  
  1516. let audio = targetCell.querySelector("audio");
  1517. if (audio) {
  1518. audio = audio.cloneNode(true);
  1519. currentImage.appendChild(audio);
  1520.  
  1521. // event listeners when hovering over the image
  1522. currentImage.addEventListener(
  1523. "mouseenter",
  1524. () => {
  1525. audio.play();
  1526. }
  1527. );
  1528. currentImage.addEventListener(
  1529. "mouseleave",
  1530. () => {
  1531. audio.pause();
  1532. }
  1533. );
  1534. }
  1535. }
  1536.  
  1537. if (targetCell) {
  1538. currentCell = targetCell;
  1539. buttonContainer.setAttribute(
  1540. "mediaLink",
  1541. targetCell.querySelector("img").src
  1542. );
  1543.  
  1544. currentImageNumber += direction === 'left' ? -1 : 1;
  1545. imageNumber.textContent = `${currentImageNumber}/${imageTotal}`;
  1546.  
  1547. // filename of the video if it has one, otherwise the filename of the image
  1548. imageTitle.textContent = video
  1549. ? video.getAttribute("fileName")
  1550. : targetCell
  1551. .querySelector("img")
  1552. .getAttribute("fileName");
  1553.  
  1554. // update view post button link
  1555. let targetMedia = video || targetCell.querySelector("img");
  1556. let targetBoard = targetMedia.getAttribute("board");
  1557. let targetThreadID = targetMedia.getAttribute("threadID");
  1558. let targetPostID = targetMedia.getAttribute("postID");
  1559. viewPostButton.href = `https://boards.4chan.org/${targetBoard}/thread/${targetThreadID}#p${targetPostID}`;
  1560.  
  1561. // update the download button links
  1562. downloadButton.href = targetMedia.src;
  1563. if (targetCell.querySelector("audio")) {
  1564. // updating audio button download link
  1565. audioDownloadButton.href =
  1566. targetCell.querySelector("audio").src;
  1567. audioDownloadButton.download = targetCell
  1568. .querySelector("audio")
  1569. .src.split("/")
  1570. .pop();
  1571. audioDownloadButton.style.display = "block";
  1572.  
  1573. // updating encoded soundpost button link
  1574. encodedSoundPostButton.href = targetCell.querySelector("audio")
  1575. .getAttribute("encodedSoundPostLink");
  1576. encodedSoundPostButton.style.display = "block";
  1577.  
  1578. } else {
  1579. audioDownloadButton.style.display = "none";
  1580. encodedSoundPostButton.style.display = "none";
  1581. }
  1582. }
  1583. }
  1584.  
  1585. const keybindHandler = (event) => {
  1586. if (event.key === "ArrowLeft") {
  1587. navigateImage('left');
  1588. } else if (event.key === "ArrowRight") {
  1589. navigateImage('right');
  1590. }
  1591. };
  1592.  
  1593. document.addEventListener("keydown", keybindHandler);
  1594.  
  1595. image.addEventListener(
  1596. "click",
  1597. () => {
  1598. image.style = "";
  1599. // image.src = thumbnailUrl;
  1600. setStyles(image, {
  1601. maxWidth: "99%",
  1602. maxHeight: `${settings.Grid_Cell_Max_Height.value}px`,
  1603. objectFit: "contain",
  1604. });
  1605.  
  1606. if (gallery.querySelector("#darkenBackground")) {
  1607. gallery.removeChild(background);
  1608. }
  1609. document.removeEventListener(
  1610. "keydown",
  1611. keybindHandler
  1612. );
  1613.  
  1614. image.addEventListener("click", zoomImage, {
  1615. once: true,
  1616. });
  1617. },
  1618. { once: true }
  1619. );
  1620. };
  1621.  
  1622. image.addEventListener("click", zoomImage, { once: true });
  1623. image.title = comment.innerText;
  1624. image.loading = "lazy";
  1625.  
  1626. if (soundLink) {
  1627. const audio = document.createElement("audio");
  1628. audio.src = decodeURIComponent(
  1629. soundLink[1].startsWith("http")
  1630. ? soundLink[1]
  1631. : `https://${soundLink[1]}`
  1632. );
  1633.  
  1634. // if switch catbox to pixstash is enabled, replace catbox.moe with pixstash.moe
  1635. if (settings.Switch_Catbox_To_Pixstash_For_Soundposts.value) {
  1636. audio.src = audio.src.replace("catbox.moe", "pixstash.moe");
  1637. }
  1638.  
  1639. audio.loop = true;
  1640. // set the attribute to the audio element with the encoded soundpost link
  1641. audio.setAttribute(
  1642. "encodedSoundPostLink",
  1643. encodedSoundPostLink
  1644. );
  1645. imageContainer.appendChild(audio);
  1646.  
  1647. image.addEventListener("mouseenter", () => {
  1648. audio.play();
  1649. });
  1650. image.addEventListener("mouseleave", () => {
  1651. audio.pause();
  1652. });
  1653.  
  1654. const playPauseButton = document.createElement("button");
  1655. playPauseButton.textContent = "Play/Pause";
  1656. setStyles(playPauseButton, {
  1657. backgroundColor: "#1c1c1c",
  1658. color: "#d9d9d9",
  1659. padding: "5px 10px",
  1660. borderRadius: "3px",
  1661. border: "none",
  1662. cursor: "pointer",
  1663. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1664. });
  1665. playPauseButton.addEventListener("click", () => {
  1666. if (audio.paused) {
  1667. audio.play();
  1668. } else {
  1669. audio.pause();
  1670. }
  1671. });
  1672. buttonDiv.appendChild(playPauseButton);
  1673. }
  1674. imageContainer.appendChild(image);
  1675. cell.appendChild(imageContainer);
  1676. } else {
  1677. return; // Skip non-video and non-image posts
  1678. }
  1679.  
  1680. // Add button that scrolls to the post in the thread
  1681. const viewPostButton = document.createElement("button");
  1682. viewPostButton.textContent = "View Post";
  1683. setStyles(viewPostButton, {
  1684. backgroundColor: "#1c1c1c",
  1685. color: "#d9d9d9",
  1686. padding: "5px 10px",
  1687. borderRadius: "3px",
  1688. border: "none",
  1689. cursor: "pointer",
  1690. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1691. });
  1692.  
  1693. viewPostButton.addEventListener("click", () => {
  1694. gallerySize = {
  1695. width: gridContainer.offsetWidth,
  1696. height: gridContainer.offsetHeight,
  1697. };
  1698. lastScrollPosition = gridContainer.scrollTop;
  1699. window.location.href = postURL + "#" + post.id;
  1700. // post id example: "pc77515440"
  1701. gallery.style.display = "none"; // hide instead of removing
  1702. });
  1703. buttonDiv.appendChild(viewPostButton);
  1704.  
  1705. // Add button that opens the media in a new tab if the media
  1706. const openInNewTabButton = document.createElement("button");
  1707. openInNewTabButton.textContent = "Open";
  1708. setStyles(openInNewTabButton, {
  1709. backgroundColor: "#1c1c1c",
  1710. color: "#d9d9d9",
  1711. padding: "5px 10px",
  1712. borderRadius: "3px",
  1713. border: "none",
  1714. cursor: "pointer",
  1715. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1716. });
  1717.  
  1718. openInNewTabButton.addEventListener("click", () => {
  1719. window.open(mediaLink, "_blank");
  1720. });
  1721. buttonDiv.appendChild(openInNewTabButton);
  1722.  
  1723. cell.appendChild(buttonDiv);
  1724. gridContainer.appendChild(cell);
  1725. }
  1726. }
  1727.  
  1728. // In the loadPosts function, update the embedded links section:
  1729. if (hasEmbeddedMediaLink) {
  1730. // Create a proper post link that includes the thread ID and post ID
  1731. const fullPostLink = postURL + "#" + post.id;
  1732. matches.forEach(url => {
  1733. createMediaCell(url, comment.innerText, mode, fullPostLink, board, threadID, postID); // Pass the current post's URL
  1734. });
  1735. }
  1736. });
  1737. });
  1738. };
  1739.  
  1740. // only load the fake image in the first thread
  1741. loadPostsFromThread(checkedThreads[0], addFakeImage);
  1742.  
  1743. // load the rest of the threads with no fake image
  1744. checkedThreads.slice(1).forEach((thread) => {
  1745. loadPostsFromThread(thread, false);
  1746. });
  1747. };
  1748.  
  1749. loadPosts(mode, addFakeImage);
  1750.  
  1751. gallery.appendChild(gridContainer);
  1752.  
  1753. const closeButton = document.createElement("button");
  1754. closeButton.textContent = "Close";
  1755. closeButton.id = "closeGallery";
  1756. setStyles(closeButton, {
  1757. position: "absolute",
  1758. bottom: "10px",
  1759. right: "10px",
  1760. zIndex: "10000",
  1761. backgroundColor: "#1c1c1c",
  1762. color: "#d9d9d9",
  1763. padding: "10px 20px",
  1764. borderRadius: "5px",
  1765. border: "none",
  1766. cursor: "pointer",
  1767. boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
  1768. });
  1769. closeButton.addEventListener("click", () => {
  1770. gallerySize = {
  1771. width: gridContainer.offsetWidth,
  1772. height: gridContainer.offsetHeight,
  1773. };
  1774. gallery.style.display = "none"; // hide instead of removing
  1775. });
  1776.  
  1777. gallery.appendChild(closeButton);
  1778.  
  1779. // Add scroll to bottom button
  1780. const scrollBottomButton = document.createElement("button");
  1781. scrollBottomButton.textContent = "Scroll to Last";
  1782. setStyles(scrollBottomButton, {
  1783. position: "fixed",
  1784. bottom: "20px",
  1785. left: "20px",
  1786. backgroundColor: "#1c1c1c",
  1787. color: "#d9d9d9",
  1788. padding: "10px 20px",
  1789. borderRadius: "5px",
  1790. border: "none",
  1791. cursor: "pointer",
  1792. zIndex: "10000",
  1793. });
  1794. scrollBottomButton.addEventListener("click", () => {
  1795. const lastCell = gridContainer.lastElementChild;
  1796. if (lastCell) {
  1797. lastCell.scrollIntoView({ behavior: "smooth" });
  1798. }
  1799. });
  1800. gallery.appendChild(scrollBottomButton);
  1801.  
  1802. // Add zoom mode arrow buttons
  1803. const background = document.createElement('div');
  1804. background.appendChild(createArrowButton('left'));
  1805. background.appendChild(createArrowButton('right'));
  1806.  
  1807. document.body.appendChild(gallery);
  1808.  
  1809. // Store the current scroll position and grid container size when closing the gallery
  1810. // (`Last scroll position: ${lastScrollPosition} px`);
  1811. gridContainer.addEventListener("scroll", () => {
  1812. lastScrollPosition = gridContainer.scrollTop;
  1813. // (`Current scroll position: ${lastScrollPosition} px`);
  1814. });
  1815.  
  1816. // Restore the last scroll position and grid container size when opening the gallery after a timeout if the url is the same
  1817. if (window.location.href.includes(threadURL.replace(/#.*$/, ""))) {
  1818. setTimeout(() => {
  1819. if (gallerySize.width > 0 && gallerySize.height > 0) {
  1820. gridContainer.style.width = `${gallerySize.width}px`;
  1821. gridContainer.style.height = `${gallerySize.height}px`;
  1822. }
  1823. // (`Restored scroll position: ${lastScrollPosition} px`);
  1824. gridContainer.scrollTop = lastScrollPosition;
  1825. }, 100);
  1826. } else {
  1827. // Reset the last scroll position and grid container size if the url is different
  1828. threadURL = window.location.href;
  1829. lastScrollPosition = 0;
  1830. gallerySize = { width: 0, height: 0 };
  1831. }
  1832.  
  1833. gallery.addEventListener("click", (event) => {
  1834. if (event.target === gallery) {
  1835. closeButton.click();
  1836. }
  1837. });
  1838. };
  1839.  
  1840. button.addEventListener("click", openImageGallery);
  1841.  
  1842. // Append the button to the body
  1843. document.body.appendChild(button);
  1844.  
  1845. if (isArchivePage) {
  1846. // adds the category to thead
  1847. const thead = document.querySelector(".flashListing thead tr");
  1848. const checkboxCell = document.createElement("td");
  1849. checkboxCell.className = "postblock";
  1850. checkboxCell.textContent = "Selected";
  1851. thead.insertBefore(checkboxCell, thead.firstChild);
  1852.  
  1853. // Add checkboxes to each thread row
  1854. const threadRows = document.querySelectorAll(".flashListing tbody tr");
  1855. threadRows.forEach((row) => {
  1856. const checkbox = document.createElement("input");
  1857. checkbox.type = "checkbox";
  1858. const checkboxCell = document.createElement("td");
  1859. checkboxCell.appendChild(checkbox);
  1860. row.insertBefore(checkboxCell, row.firstChild);
  1861. });
  1862. }
  1863. };
  1864.  
  1865. // Use the "i" key to open and close the gallery/grid
  1866. document.addEventListener("keydown", (event) => {
  1867. if (event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA") {
  1868. return;
  1869. }
  1870. if (event.key === settings.Open_Close_Gallery_Key.value) {
  1871. if (!document.querySelector("#imageGallery")) {
  1872. document.querySelector("#openImageGallery").click();
  1873. return;
  1874. }
  1875.  
  1876. if (document.querySelector("#imageGallery").style.display === "none") {
  1877. document.querySelector("#openImageGallery").click();
  1878. } else {
  1879. document.querySelector("#closeGallery").click();
  1880. }
  1881. }
  1882. });
  1883.  
  1884. loadButton();
  1885. console.log("4chan Gallery loaded successfully!");
  1886. })();