🏠 Home 

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.


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