// ==UserScript==
// @name 4chan Gallery
// @namespace http://tampermonkey.net/
// @version 2024-03-29 (1.5)
// @description 4chan grid based Image Gallery for threads that can load images, images with sounds, webms with sounds (Button on the Bottom Right)
// @author TheDarkEnjoyer
// @match http://boards.4chan.org/*/thread/*
// @match http://boards.4chan.org/*/archive
// @match https://boards.4chan.org/*/thread/*
// @match https://boards.4chan.org/*/archive
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// @license GNU GPLv3
// ==/UserScript==
(function () {
"use strict";
let threadURL = window.location.href;
let lastScrollPosition = 0;
let gallerySize = { width: 0, height: 0 };
function setStyles(element, styles) {
for (const property in styles) {
element.style[property] = styles[property];
}
}
const loadButton = () => {
const isArchivePage = window.location.pathname.includes("/archive");
const button = document.createElement("button");
button.textContent = "Open Image Gallery";
setStyles(button, {
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: "1000",
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
});
const openImageGallery = () => {
const gallery = document.createElement("div");
setStyles(gallery, {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.8)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: "9999",
});
const gridContainer = document.createElement("div");
setStyles(gridContainer, {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "10px",
padding: "20px",
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
maxWidth: "80%",
maxHeight: "80%",
overflowY: "auto",
resize: "both",
overflow: "auto",
border: "1px solid var(--text-color)",
});
// Restore the previous grid container size
if (gallerySize.width > 0 && gallerySize.height > 0) {
gridContainer.style.width = `${gallerySize.width}px`;
gridContainer.style.height = `${gallerySize.height}px`;
}
let mode = "all"; // Default mode is "all"
let autoPlayWebms = false; // Default auto play webms without sound is false
// Toggle mode button
const toggleModeButton = document.createElement("button");
toggleModeButton.textContent = "Toggle Mode (All)";
setStyles(toggleModeButton, {
position: "absolute",
top: "10px",
left: "10px",
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
});
toggleModeButton.addEventListener("click", () => {
mode = mode === "all" ? "webm" : "all";
toggleModeButton.textContent = `Toggle Mode (${mode === "all" ? "All" : "Webm"})`;
gridContainer.innerHTML = ""; // Clear the grid
loadPosts(mode); // Reload posts based on the new mode
});
gallery.appendChild(toggleModeButton);
// Toggle auto play webms button
const toggleAutoPlayButton = document.createElement("button");
toggleAutoPlayButton.textContent = "Auto Play Webms without Sound";
setStyles(toggleAutoPlayButton, {
position: "absolute",
top: "10px",
left: "200px",
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
});
toggleAutoPlayButton.addEventListener("click", () => {
autoPlayWebms = !autoPlayWebms;
toggleAutoPlayButton.textContent = autoPlayWebms ? "Stop Auto Play Webms" : "Auto Play Webms without Sound";
gridContainer.innerHTML = ""; // Clear the grid
loadPosts(mode); // Reload posts based on the new mode and auto play setting
});
gallery.appendChild(toggleAutoPlayButton);
const loadPosts = (mode) => {
const checkedThreads = isArchivePage
? Array.from(document.querySelectorAll(".flashListing input[type='checkbox']:checked")).map((checkbox) => checkbox.parentNode.parentNode)
: []; // Use an empty array for non-archive pages
const loadPostsFromThread = (thread) => {
// thread number is the 2nd child of the parent node
const threadNo = thread.children[1].textContent;
// get current board
const board = window.location.pathname.split("/")[1];
const threadURL = `https://boards.4chan.org/${board}/thread/${threadNo}`;
fetch(threadURL)
.then((response) => response.text())
.then((html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const posts = doc.querySelectorAll(".postContainer");
posts.forEach((post) => {
let mediaLink = post.querySelector(".fileText a");
if (post.querySelector(".fileText-original")) {
mediaLink = post.querySelector(".fileText-original a");
}
const comment = post.querySelector(".postMessage");
if (mediaLink) {
const isVideo = mediaLink.href.includes(".webm");
const fileName = mediaLink.href.split("/").pop();
const soundLink = mediaLink.title.match(/\[sound=(.+?)\]/);
// Check if the post should be loaded based on the mode
if (mode === "all" || (mode === "webm" && isVideo)) {
const cell = document.createElement("div");
setStyles(cell, {
border: "1px solid var(--text-color)",
position: "relative",
});
const buttonDiv = document.createElement("div");
setStyles(buttonDiv, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "5px",
});
if (isVideo) {
const videoContainer = document.createElement("div");
setStyles(videoContainer, {
position: "relative",
});
const video = document.createElement("video");
video.src = mediaLink.href;
setStyles(video, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
});
video.muted = true;
video.controls = true;
video.title = comment.textContent;
// Play webms without sound automatically on hover or if autoPlayWebms is true
if (!soundLink) {
if (autoPlayWebms) {
video.play();
video.loop = true; // Loop webms when autoPlayWebms is true
} else {
video.addEventListener("mouseenter", () => {
video.play();
});
video.addEventListener("mouseleave", () => {
video.pause();
});
}
}
video.addEventListener("click", () => {
post.scrollIntoView({ behavior: "smooth" });
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
videoContainer.appendChild(video);
if (soundLink) {
const audio = document.createElement("audio");
audio.src = decodeURIComponent(soundLink[1].startsWith("http") ? soundLink[1] : `https://${soundLink[1]}`);
videoContainer.appendChild(audio);
const playPauseButton = document.createElement("button");
playPauseButton.textContent = "Play/Pause";
playPauseButton.addEventListener("click", () => {
if (video.paused && audio.paused) {
video.play();
audio.play();
} else {
video.pause();
audio.pause();
}
});
buttonDiv.appendChild(playPauseButton);
const resetButton = document.createElement("button");
resetButton.textContent = "Reset";
resetButton.addEventListener("click", () => {
video.currentTime = 0;
audio.currentTime = 0;
});
buttonDiv.appendChild(resetButton);
let lastVideoTime = 0;
// Sync audio with video on timeupdate event only if the difference is 2 seconds or more
video.addEventListener("timeupdate", () => {
if (Math.abs(video.currentTime - lastVideoTime) >= 2) {
audio.currentTime = video.currentTime;
lastVideoTime = video.currentTime;
}
lastVideoTime = video.currentTime;
});
}
const cellButton = document.createElement("button");
cellButton.textContent = "View Post";
setStyles(cellButton, {
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
});
cellButton.addEventListener("click", () => {
post.scrollIntoView({ behavior: "smooth" });
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
buttonDiv.appendChild(cellButton);
cell.appendChild(videoContainer);
} else {
const image = document.createElement("img");
image.src = mediaLink.href;
setStyles(image, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
});
image.addEventListener("click", () => {
post.scrollIntoView({ behavior: "smooth" });
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
image.title = comment.textContent;
if (soundLink) {
const audio = document.createElement("audio");
audio.src = decodeURIComponent(soundLink[1].startsWith("http") ? soundLink[1] : `https://${soundLink[1]}`);
const playPauseButton = document.createElement("button");
playPauseButton.textContent = "Play/Pause";
setStyles(playPauseButton, {
position: "absolute",
bottom: "10px",
left: "10px",
});
playPauseButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
});
buttonDiv.appendChild(playPauseButton);
}
cell.appendChild(image);
}
cell.appendChild(buttonDiv);
gridContainer.appendChild(cell);
}
}
});
})
.catch((error) => console.error(error));
};
if (isArchivePage) {
checkedThreads.forEach(loadPostsFromThread);
} else {
const posts = document.querySelectorAll(".postContainer");
posts.forEach((post) => {
let mediaLink = post.querySelector(".fileText a");
if (post.querySelector(".fileText-original")) {
mediaLink = post.querySelector(".fileText-original a");
}
const comment = post.querySelector(".postMessage");
if (mediaLink) {
const isVideo = mediaLink.href.includes(".webm");
const fileName = mediaLink.href.split("/").pop();
const soundLink = mediaLink.title.match(/\[sound=(.+?)\]/);
// Check if the post should be loaded based on the mode
if (mode === "all" || (mode === "webm" && isVideo)) {
const cell = document.createElement("div");
setStyles(cell, {
border: "1px solid var(--text-color)",
position: "relative",
});
const buttonDiv = document.createElement("div");
setStyles(buttonDiv, {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "5px",
});
if (isVideo) {
const videoContainer = document.createElement("div");
setStyles(videoContainer, {
position: "relative",
});
const video = document.createElement("video");
video.src = mediaLink.href;
setStyles(video, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
});
video.muted = true;
video.controls = true;
video.title = comment.textContent;
// Play webms without sound automatically on hover or if autoPlayWebms is true
if (!soundLink) {
if (autoPlayWebms) {
video.play();
video.loop = true; // Loop webms when autoPlayWebms is true
} else {
video.addEventListener("mouseenter", () => {
video.play();
});
video.addEventListener("mouseleave", () => {
video.pause();
});
}
}
video.addEventListener("click", () => {
post.scrollIntoView({ behavior: "smooth" });
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
videoContainer.appendChild(video);
if (soundLink) {
const audio = document.createElement("audio");
audio.src = decodeURIComponent(soundLink[1].startsWith("http") ? soundLink[1] : `https://${soundLink[1]}`);
videoContainer.appendChild(audio);
const playPauseButton = document.createElement("button");
playPauseButton.textContent = "Play/Pause";
playPauseButton.addEventListener("click", () => {
if (video.paused && audio.paused) {
video.play();
audio.play();
} else {
video.pause();
audio.pause();
}
});
buttonDiv.appendChild(playPauseButton);
const resetButton = document.createElement("button");
resetButton.textContent = "Reset";
resetButton.addEventListener("click", () => {
video.currentTime = 0;
audio.currentTime = 0;
});
buttonDiv.appendChild(resetButton);
let lastVideoTime = 0;
// Sync audio with video on timeupdate event only if the difference is 2 seconds or more
video.addEventListener("timeupdate", () => {
if (Math.abs(video.currentTime - lastVideoTime) >= 2) {
audio.currentTime = video.currentTime;
lastVideoTime = video.currentTime;
}
lastVideoTime = video.currentTime;
});
}
const cellButton = document.createElement("button");
cellButton.textContent = "View Post";
setStyles(cellButton, {
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
});
cellButton.addEventListener("click", () => {
post.scrollIntoView({ behavior: "smooth" });
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
buttonDiv.appendChild(cellButton);
cell.appendChild(videoContainer);
} else {
const image = document.createElement("img");
image.src = mediaLink.href;
setStyles(image, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
});
image.addEventListener("click", () => {
post.scrollIntoView({ behavior: "smooth" });
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
image.title = comment.textContent;
if (soundLink) {
const audio = document.createElement("audio");
audio.src = decodeURIComponent(soundLink[1].startsWith("http") ? soundLink[1] : `https://${soundLink[1]}`);
const playPauseButton = document.createElement("button");
playPauseButton.textContent = "Play/Pause";
setStyles(playPauseButton, {
position: "absolute",
bottom: "10px",
left: "10px",
});
playPauseButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
});
buttonDiv.appendChild(playPauseButton);
}
cell.appendChild(image);
}
cell.appendChild(buttonDiv);
gridContainer.appendChild(cell);
}
}
});
}
};
loadPosts(mode); // Load posts based on the initial mode
gallery.appendChild(gridContainer);
const closeButton = document.createElement("button");
closeButton.textContent = "Close";
setStyles(closeButton, {
position: "absolute",
top: "10px",
right: "10px",
zIndex: "10000",
backgroundColor: "var(--main-color)",
color: "var(--text-color)",
});
closeButton.addEventListener("click", () => {
gallerySize = {
width: gridContainer.offsetWidth,
height: gridContainer.offsetHeight,
};
document.body.removeChild(gallery);
});
gallery.appendChild(closeButton);
document.body.appendChild(gallery);
// Store the current scroll position and grid container size when closing the gallery
console.log(`Last scroll position: ${lastScrollPosition} px`);
gridContainer.addEventListener("scroll", () => {
lastScrollPosition = gridContainer.scrollTop;
console.log(`Current scroll position: ${lastScrollPosition} px`);
});
// Restore the last scroll position and grid container size when opening the gallery after a timeout if the url is the same
if (window.location.href === threadURL) {
setTimeout(() => {
gridContainer.scrollTop = lastScrollPosition;
console.log(`Restored scroll position: ${lastScrollPosition} px`);
if (gallerySize.width > 0 && gallerySize.height > 0) {
gridContainer.style.width = `${gallerySize.width}px`;
gridContainer.style.height = `${gallerySize.height}px`;
}
}, 200);
} else {
// Reset the last scroll position and grid container size if the url is different
threadURL = window.location.href;
lastScrollPosition = 0;
gallerySize = { width: 0, height: 0 };
}
};
button.addEventListener("click", openImageGallery);
// Append the button to the body
document.body.appendChild(button);
if (isArchivePage) {
// Add checkboxes to each thread row
const threadRows = document.querySelectorAll(".flashListing tbody tr");
threadRows.forEach((row) => {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
const checkboxCell = document.createElement("td");
checkboxCell.appendChild(checkbox);
row.insertBefore(checkboxCell, row.firstChild);
});
}
};
// Check if there are at least two posts before loading the button
const posts = document.querySelectorAll(".postContainer");
if (posts.length >= 2) {
loadButton();
} else {
// If there are less than two posts, try again after 5 seconds
setTimeout(loadButton, 1);
}
console.log("4chan Gallery loaded successfully!");
})();