// ==UserScript==
// @name 4chan Gallery
// @namespace http://tampermonkey.net/
// @version 2024-08-09 (2.8)
// @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.
// @author TheDarkEnjoyer
// @match https://boards.4chan.org/*/thread/*
// @match https://boards.4chan.org/*/archive
// @match https://boards.4channel.org/*/thread/*
// @match https://boards.4channel.org/*/archive
// @match https://warosu.org/*/*
// @match https://archived.moe/*/*
// @match https://archive.palanq.win/*/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// @license GNU GPLv3
// ==/UserScript==
(function () {
"use strict";
// injectVideoJS();
const defaultSettings = {
Load_High_Res_Images_By_Default: {
value: false,
info: "When opening the gallery, load high quality images by default (no thumbnails)",
},
Add_Placeholder_Image_For_Zoom_Mode: {
value: true,
info: "Add a placeholder image for zoom mode so even if the thread has no images, you can still open the zoom mode",
},
Play_Webms_On_Hover: {
value: true,
info: "Autoplay webms on hover, pause on mouse leave",
},
};
let threadURL = window.location.href;
let lastScrollPosition = 0;
let gallerySize = { width: 0, height: 0 };
// store settings in local storage
if (!localStorage.getItem("gallerySettings")) {
localStorage.setItem("gallerySettings", JSON.stringify(defaultSettings));
}
let settings = JSON.parse(localStorage.getItem("gallerySettings"));
// check if settings has all the keys from defaultSettings, if not, add the missing keys
let missingSetting = false;
for (const setting in defaultSettings) {
if (!settings.hasOwnProperty(setting)) {
settings[setting] = defaultSettings[setting];
missingSetting = true;
}
}
// update the settings in local storage if there are missing settings
if (missingSetting) {
localStorage.setItem("gallerySettings", JSON.stringify(settings));
}
function setStyles(element, styles) {
for (const property in styles) {
element.style[property] = styles[property];
}
}
function getPosts(websiteUrl, doc) {
switch (websiteUrl) {
case "warosu.org":
return doc.querySelectorAll(".comment");
case "archived.moe":
case "archive.palanq.win":
return doc.querySelectorAll(".has_image");
case "boards.4chan.org":
case "boards.4channel.org":
default:
return doc.querySelectorAll(".postContainer");
}
}
function getDocument(thread, threadURL) {
return new Promise((resolve, reject) => {
if (thread === threadURL) {
resolve(document);
} else {
fetch(thread)
.then((response) => response.text())
.then((html) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
resolve(doc);
})
.catch((error) => {
reject(error);
});
}
});
}
function injectVideoJS() {
const link = document.createElement("link");
link.href = "https://vjs.zencdn.net/8.10.0/video-js.css";
link.rel = "stylesheet";
document.head.appendChild(link);
// theme
const theme = document.createElement("link");
theme.href = "https://unpkg.com/@videojs/themes@1/dist/city/index.css";
theme.rel = "stylesheet";
document.head.appendChild(theme);
const script = document.createElement("script");
script.src = "https://vjs.zencdn.net/8.10.0/video.min.js";
document.body.appendChild(script);
("VideoJS injected successfully!");
}
const loadButton = () => {
const isArchivePage = window.location.pathname.includes("/archive");
let addFakeImage = settings.Add_Placeholder_Image_For_Zoom_Mode.value;
const button = document.createElement("button");
button.textContent = "Open Image Gallery";
button.id = "openImageGallery";
setStyles(button, {
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: "1000",
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
const openImageGallery = () => {
const gallery = document.createElement("div");
gallery.id = "imageGallery";
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(3, 1fr)`,
gridTemplateRows: `repeat(2, 1fr)`,
gap: "10px",
padding: "20px",
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
maxWidth: "80%",
maxHeight: "80%",
overflowY: "auto",
resize: "both",
overflow: "auto",
border: "1px solid #d9d9d9",
});
// 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
// top left corner of the screen
const mediaTypeButtonContainer = document.createElement("div");
setStyles(mediaTypeButtonContainer, {
position: "absolute",
top: "10px",
left: "10px",
display: "flex",
gap: "10px",
});
// Toggle mode button
const toggleModeButton = document.createElement("button");
toggleModeButton.textContent = "Toggle Mode (All)";
setStyles(toggleModeButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
toggleModeButton.addEventListener("click", () => {
mode = mode === "all" ? "webm" : "all";
toggleModeButton.textContent = `Toggle Mode (${mode === "all" ? "All" : "Webm & Images with Sound"
})`;
gridContainer.innerHTML = ""; // Clear the grid
loadPosts(mode, addFakeImage); // Reload posts based on the new mode
});
// Toggle auto play webms button
const toggleAutoPlayButton = document.createElement("button");
toggleAutoPlayButton.textContent = "Auto Play Webms without Sound";
setStyles(toggleAutoPlayButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
toggleAutoPlayButton.addEventListener("click", () => {
autoPlayWebms = !autoPlayWebms;
toggleAutoPlayButton.textContent = autoPlayWebms
? "Stop Auto Play Webms"
: "Auto Play Webms without Sound";
gridContainer.innerHTML = ""; // Clear the grid
loadPosts(mode, addFakeImage); // Reload posts based on the new mode and auto play setting
});
mediaTypeButtonContainer.appendChild(toggleModeButton);
mediaTypeButtonContainer.appendChild(toggleAutoPlayButton);
gallery.appendChild(mediaTypeButtonContainer);
// settings button on the top right corner of the screen
const settingsButton = document.createElement("button");
settingsButton.id = "settingsButton";
settingsButton.textContent = "Settings";
setStyles(settingsButton, {
position: "absolute",
top: "20px",
right: "20px",
backgroundColor: "#007bff", // Primary color
color: "#fff",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
transition: "background-color 0.3s ease",
});
settingsButton.addEventListener("click", () => {
const settingsContainer = document.createElement("div");
settingsContainer.id = "settingsContainer";
setStyles(settingsContainer, {
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",
animation: "fadeIn 0.3s ease",
});
const settingsBox = document.createElement("div");
setStyles(settingsBox, {
backgroundColor: "#000000", // Background color
color: "#ffffff", // Text color
padding: "30px",
borderRadius: "10px",
border: "1px solid #6c757d", // Secondary color
maxWidth: "80%",
maxHeight: "80%",
overflowY: "auto",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
});
const settingsTitle = document.createElement("h2");
settingsTitle.id = "settingsTitle";
settingsTitle.textContent = "Settings";
setStyles(settingsTitle, {
textAlign: "center",
marginBottom: "20px",
});
const settingsList = document.createElement("ul");
settingsList.id = "settingsList";
setStyles(settingsList, {
listStyleType: "none",
padding: "0",
margin: "0",
});
// include default settings as existing settings inside the input fields
// have an icon next to the setting that explains what the setting does
for (const setting in settings) {
const settingItem = document.createElement("li");
setStyles(settingItem, {
display: "flex",
alignItems: "center",
marginBottom: "15px",
});
const settingLabel = document.createElement("label");
settingLabel.textContent = setting.replace(/_/g, " ");
settingLabel.title = settings[setting].info;
setStyles(settingLabel, {
flex: "1",
display: "flex",
alignItems: "center",
});
const settingIcon = document.createElement("span");
settingIcon.className = "material-icons-outlined";
settingIcon.textContent = settings[setting].icon;
settingIcon.style.marginRight = "10px";
settingLabel.prepend(settingIcon);
settingItem.appendChild(settingLabel);
const settingInput = document.createElement("input");
const settingValueType = typeof defaultSettings[setting].value;
if (settingValueType === "boolean") {
settingInput.type = "checkbox";
settingInput.checked = settings[setting].value;
} else if (settingValueType === "number") {
settingInput.type = "number";
settingInput.value = settings[setting].value;
} else {
settingInput.type = "text";
settingInput.value = settings[setting].value;
}
setStyles(settingInput, {
padding: "8px 12px",
borderRadius: "5px",
border: "1px solid #6c757d", // Secondary color
flex: "2",
});
settingInput.addEventListener("focus", () => {
setStyles(settingInput, {
borderColor: "#007bff", // Primary color
boxShadow: "0 0 0 2px rgba(0, 123, 255, 0.25)",
outline: "none",
});
});
settingInput.addEventListener("blur", () => {
setStyles(settingInput, {
borderColor: "#6c757d", // Secondary color
boxShadow: "none",
});
});
if (settingValueType === "boolean") {
settingInput.style.marginRight = "10px";
}
settingItem.appendChild(settingInput);
settingsList.appendChild(settingItem);
}
const saveButton = document.createElement("button");
saveButton.id = "saveButton";
saveButton.textContent = "Save";
setStyles(saveButton, {
backgroundColor: "#007bff", // Primary color
color: "#fff",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
transition: "background-color 0.3s ease",
marginRight: "10px",
});
saveButton.addEventListener("click", () => {
const newSettings = defaultSettings;
const inputs = document.querySelectorAll("#settingsList input");
inputs.forEach((input) => {
const settingName = input.previousSibling.textContent.replace(
/ /g,
"_"
);
const settingValue =
typeof defaultSettings[settingName].value === "boolean"
? input.checked
: input.value;
newSettings[settingName].value = settingValue;
});
localStorage.setItem("gallerySettings", JSON.stringify(newSettings));
settings = newSettings;
settingsContainer.remove();
gridContainer.innerHTML = ""; // Clear the grid
loadPosts(mode, addFakeImage); // Reload posts based on the new settings
});
// Close button
const closeButton = document.createElement("button");
closeButton.id = "closeButton";
closeButton.textContent = "Close";
setStyles(closeButton, {
backgroundColor: "#007bff", // Primary color
color: "#fff",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
transition: "background-color 0.3s ease",
});
closeButton.addEventListener("click", () => {
settingsContainer.remove();
});
settingsBox.appendChild(settingsTitle);
settingsBox.appendChild(settingsList);
settingsBox.appendChild(saveButton);
settingsBox.appendChild(closeButton);
settingsContainer.appendChild(settingsBox);
gallery.appendChild(settingsContainer);
});
// Hover effect for settings button
settingsButton.addEventListener("mouseenter", () => {
settingsButton.style.backgroundColor = "#0056b3";
});
settingsButton.addEventListener("mouseleave", () => {
settingsButton.style.backgroundColor = "#007bff";
});
gallery.appendChild(settingsButton);
const loadPosts = (mode) => {
const checkedThreads = isArchivePage
? // Get all checked threads in the archive page or the current link if it's not an archive page
Array.from(
document.querySelectorAll(
".flashListing input[type='checkbox']:checked"
)
).map((checkbox) => {
let archiveSite =
checkbox.parentNode.parentNode.querySelector("a").href;
return archiveSite;
})
: [threadURL];
const loadPostsFromThread = (thread, addFakeImage) => {
// get the website url without the protocol and next slash
let websiteUrl = thread.replace(/(^\w+:|^)\/\//, "").split("/")[0];
// const board = thread.split("/thread/")[0].split("/").pop();
// const threadNo = `${parseInt(thread.split("thread/").pop())}`
getDocument(thread, threadURL).then((doc) => {
let posts;
// use a case statement to deal with different websites
posts = getPosts(websiteUrl, doc);
// add thread and website url as attributes to every post
posts.forEach((post) => {
post.setAttribute("thread", thread);
post.setAttribute("websiteUrl", websiteUrl);
});
if (addFakeImage) {
// Add a fake image to the grid container to allow zoom mode to open even if the thread has no images
let placeholder_imageURL = "https://files.catbox.moe/ecl8vh.png";
let examplePost = document.createElement("div");
examplePost.innerHTML = `
<div class="postContainer", id="1231232">
<div class="fileText">
<a href="${placeholder_imageURL}" download="${placeholder_imageURL}">OpenZoomMode[sound=https://files.catbox.moe/brugtt.mp3].jpg</a>
</div>
<div class="fileThumb">
<img src="${placeholder_imageURL}" alt="Thumbnail">
</div>
<div class="postMessage">
Just a placeholder image for zoom mode
</div>
</div>
`;
examplePost.setAttribute("thread", "https://boards.4chan.org/b/thread/123456789");
examplePost.setAttribute("websiteUrl", "boards.4chan.org");
posts = [examplePost, ...posts];
}
posts.forEach((post) => {
let mediaLinkFlag = false;
let board;
let threadID;
let postID;
let postURL;
let thumbnailUrl;
let mediaLink;
let fileName;
let comment;
let isVideo;
let isImage;
let soundLink;
let encodedSoundPostLink;
let temp;
websiteUrl = post.getAttribute("websiteUrl");
thread = post.getAttribute("thread");
// case statement for different websites
switch (websiteUrl) {
case "warosu.org":
let thumbnailElement = post.querySelector(".thumb");
fileName = post
.querySelector(".fileinfo")
?.innerText.split(", ")[2];
thumbnailUrl = thumbnailElement?.src;
mediaLink = thumbnailElement?.parentNode.href;
comment = post.querySelector("blockquote");
threadID = post.querySelector(".js").href.match(/thread\/(\d+)/)[1];
postID = post.id.replace("pc", "").replace("p", "");
break;
case "archived.moe":
case "archive.palanq.win":
thumbnailUrl = post.querySelector(".post_image").src;
mediaLink = post.querySelector(".thread_image_link").href;
fileName = post.querySelector(
".post_file_filename"
).title;
comment = post.querySelector(".text");
threadID = post.querySelector(".post_data > a").href.match(
/thread\/(\d+)/
)[1];
postID = post.id
break;
case "boards.4chan.org":
case "boards.4channel.org":
default:
if (!post.querySelector(".fileText")) {
return; // Skip posts without media links
}
// if they have 4chanX installed, there will be a fileText-orignal class
if (post.querySelector(".download-button")) {
temp = post.querySelector(".download-button");
mediaLink = temp.href;
fileName = temp.download;
} else {
if (post.classList.contains("opContainer")) {
mediaLink = post.querySelector(".fileText a");
temp = mediaLink;
} else {
mediaLink = post.querySelector(".fileText");
temp = mediaLink.querySelector("a");
}
if (mediaLink.title === "") {
if (temp.title === "") {
fileName = temp.innerText;
} else {
fileName = temp.title;
}
} else {
fileName = mediaLink.title;
}
mediaLink = temp.href;
}
thumbnailUrl = post.querySelector(".fileThumb img")?.src;
comment = post.querySelector(".postMessage");
threadID = thread.match(/thread\/(\d+)/)[1];
postID = post.id.replace("pc", "").replace("p", "");
}
if (mediaLink) {
isVideo = mediaLink.includes(".webm");
isImage =
mediaLink.includes(".jpg") ||
mediaLink.includes(".png") ||
mediaLink.includes(".gif");
soundLink = fileName.match(/\[sound=(.+?)\]/);
mediaLinkFlag = true;
} else {
return; // Skip posts without media links
}
// replace the "#pcXXXXXXX" or "#pXXXXXXX" with an empty string to get the actual thread url
if (thread.includes("#")) {
postURL = thread.replace(/#p\d+/, "");
postURL = postURL.replace(/#pc\d+/, "");
} else {
postURL = thread;
}
// post info (constant)
board = thread.match(/\/\/[^\/]+\/([^\/]+)/)[1];
if (soundLink) {
encodedSoundPostLink = `https://4chan.mahdeensky.top/${board}/thread/${threadID}/${postID}`;
}
if (mediaLinkFlag) {
// Check if the post should be loaded based on the mode
if (
mode === "all" ||
(mode === "webm" && (isVideo || (isImage && soundLink)))
) {
const cell = document.createElement("div");
setStyles(cell, {
border: "1px solid #d9d9d9",
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",
display: "flex",
justifyContent: "center",
});
const videoThumbnail = document.createElement("img");
videoThumbnail.src = thumbnailUrl;
videoThumbnail.alt = "Video Thumbnail";
setStyles(videoThumbnail, {
width: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
});
videoThumbnail.loading = "lazy";
const video = document.createElement("video");
video.src = mediaLink;
video.controls = true;
video.title = comment.innerText;
video.videothumbnailDisplayed = "true";
video.setAttribute("fileName", fileName);
video.setAttribute("board", board);
video.setAttribute("threadID", threadID);
video.setAttribute("postID", postID);
setStyles(video, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
display: "none",
});
// videoJS stuff (not working for some reason)
// video.className = "video-js";
// video.setAttribute("data-setup", "{}");
// const source = document.createElement("source");
// source.src = mediaLink;
// source.type = "video/webm";
// video.appendChild(source);
videoThumbnail.addEventListener("click", () => {
videoThumbnail.style.display = "none";
video.style.display = "block";
video.videothumbnailDisplayed = "false";
// video.load();
});
// hide the video thumbnail and show the video when hovered
videoThumbnail.addEventListener("mouseenter", () => {
videoThumbnail.style.display = "none";
video.style.display = "block";
video.videothumbnailDisplayed = "false";
// video.load();
});
// Play webms without sound automatically on hover or if autoPlayWebms is true
if (!soundLink) {
if (autoPlayWebms) {
video.addEventListener("canplaythrough", () => {
video.play();
video.loop = true; // Loop webms when autoPlayWebms is true
});
} else {
if (settings.Play_Webms_On_Hover.value) {
video.addEventListener("mouseenter", () => {
video.play();
});
video.addEventListener("mouseleave", () => {
video.pause();
});
}
}
}
videoContainer.appendChild(videoThumbnail);
videoContainer.appendChild(video);
if (soundLink) {
// video.preload = "none"; // Disable video preload for better performance
const audio = document.createElement("audio");
audio.src = decodeURIComponent(
soundLink[1].startsWith("http")
? soundLink[1]
: `https://${soundLink[1]}`
);
// add attribute to the audio element with the encoded soundpost link
audio.setAttribute(
"encodedSoundPostLink",
encodedSoundPostLink
);
videoContainer.appendChild(audio);
const resetButton = document.createElement("button");
resetButton.textContent = "Reset";
setStyles(resetButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
resetButton.addEventListener("click", () => {
video.currentTime = 0;
audio.currentTime = 0;
});
buttonDiv.appendChild(resetButton);
// html5 video play
video.onplay = (event) => {
audio.play();
};
video.onpause = (event) => {
audio.pause();
};
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;
});
}
cell.appendChild(videoContainer);
} else if (isImage) {
const imageContainer = document.createElement("div");
setStyles(imageContainer, {
position: "relative",
display: "flex",
justifyContent: "center",
alignItems: "center",
});
const image = document.createElement("img");
image.src = thumbnailUrl;
if (settings.Load_High_Res_Images_By_Default.value) {
image.src = mediaLink;
}
if (mediaLink.includes(".gif")) {
image.src = mediaLink;
}
image.setAttribute("fileName", fileName);
image.setAttribute("actualSrc", mediaLink);
image.setAttribute("thumbnailUrl", thumbnailUrl);
image.setAttribute("board", board);
image.setAttribute("threadID", threadID);
image.setAttribute("postID", postID);
setStyles(image, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
cursor: "pointer",
});
let createDarkenBackground = () => {
const background = document.createElement("div");
background.id = "darkenBackground";
setStyles(background, {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.3)",
backdropFilter: "blur(5px)",
zIndex: "9999",
});
return background;
};
let zoomImage = () => {
// have the image pop up centered in front of the screen so that it fills about 80% of the screen
image.style = "";
image.src = mediaLink;
setStyles(image, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "10000",
height: "80%",
width: "80%",
objectFit: "contain",
cursor: "pointer",
});
// darken and blur the background behind the image without affecting the image
const background = createDarkenBackground();
gallery.appendChild(background);
// create a container for the buttons, number, and download buttons (even space between them)
// position: fixed; bottom: 10px; display: flex; flex-direction: row; justify-content: space-around; z-index: 10000; width: 100%; margin:auto;
const bottomContainer = document.createElement("div");
setStyles(bottomContainer, {
position: "fixed",
bottom: "10px",
display: "flex",
flexDirection: "row",
justifyContent: "space-around",
zIndex: "10000",
width: "100%",
margin: "auto",
});
background.appendChild(bottomContainer);
// buttons on the bottom left of the screen for reverse image search (SauceNAO, Google Lens, Yandex)
const buttonContainer = document.createElement("div");
setStyles(buttonContainer, {
display: "flex",
gap: "10px",
});
buttonContainer.setAttribute("mediaLink", mediaLink);
const sauceNAOButton = document.createElement("button");
sauceNAOButton.textContent = "SauceNAO";
setStyles(sauceNAOButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
});
sauceNAOButton.addEventListener("click", () => {
window.open(
`https://saucenao.com/search.php?url=${encodeURIComponent(
buttonContainer.getAttribute("mediaLink")
)}`
);
});
buttonContainer.appendChild(sauceNAOButton);
const googleLensButton = document.createElement("button");
googleLensButton.textContent = "Google Lens";
setStyles(googleLensButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
googleLensButton.addEventListener("click", () => {
window.open(
`https://lens.google.com/uploadbyurl?url=${encodeURIComponent(
buttonContainer.getAttribute("mediaLink")
)}`
);
});
buttonContainer.appendChild(googleLensButton);
const yandexButton = document.createElement("button");
yandexButton.textContent = "Yandex";
setStyles(yandexButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
yandexButton.addEventListener("click", () => {
window.open(
`https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(
buttonContainer.getAttribute("mediaLink")
)}`
);
});
buttonContainer.appendChild(yandexButton);
bottomContainer.appendChild(buttonContainer);
// download container for video/img and audio
const downloadButtonContainer =
document.createElement("div");
setStyles(downloadButtonContainer, {
display: "flex",
gap: "10px",
});
bottomContainer.appendChild(downloadButtonContainer);
const viewPostButton = document.createElement("a");
viewPostButton.textContent = "View Post";
viewPostButton.href = `https://boards.4chan.org/${board}/thread/${threadID}#p${postID}`;
setStyles(viewPostButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
downloadButtonContainer.appendChild(viewPostButton);
const downloadButton = document.createElement("a");
downloadButton.textContent = "Download Video/Image";
downloadButton.href = mediaLink;
downloadButton.download = fileName;
downloadButton.target = "_blank";
setStyles(downloadButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
downloadButtonContainer.appendChild(downloadButton);
const audioDownloadButton = document.createElement("a");
audioDownloadButton.textContent = "Download Audio";
audioDownloadButton.target = "_blank";
setStyles(audioDownloadButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
if (soundLink) {
audioDownloadButton.href = decodeURIComponent(
soundLink[1].startsWith("http")
? soundLink[1]
: `https://${soundLink[1]}`
);
audioDownloadButton.download = soundLink[1]
.split("/")
.pop();
} else {
audioDownloadButton.style.display = "none";
}
downloadButtonContainer.appendChild(audioDownloadButton);
// 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
const encodedSoundPostButton =
document.createElement("a");
encodedSoundPostButton.textContent =
"Download Encoded Soundpost";
encodedSoundPostButton.target = "_blank";
setStyles(encodedSoundPostButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
if (soundLink) {
encodedSoundPostButton.href = `https://4chan.mahdeensky.top/${board}/thread/${threadID}/${postID}`;
} else {
encodedSoundPostButton.style.display = "none";
}
downloadButtonContainer.appendChild(
encodedSoundPostButton
);
// number on the bottom right of the screen to show which image is currently being viewed
const imageNumber = document.createElement("div");
let currentImageNumber =
Array.from(cell.parentNode.children).indexOf(cell) + 1;
let imageTotal = cell.parentNode.children.length;
imageNumber.textContent = `${currentImageNumber}/${imageTotal}`;
setStyles(imageNumber, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
zIndex: "10000",
});
bottomContainer.appendChild(imageNumber);
// title of the image/video on the top left of the screen
const imageTitle = document.createElement("div");
imageTitle.textContent = fileName;
setStyles(imageTitle, {
position: "fixed",
top: "10px",
left: "10px",
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
zIndex: "10000",
});
background.appendChild(imageTitle);
let currentCell = cell;
// use left and right arrow keys to navigate between images/videos
let keybindHandler = (event) => {
if (event.key === "ArrowLeft") {
// get the previous cell in the grid
const previousCell =
currentCell.previousElementSibling;
if (previousCell) {
if (gallery.querySelector("#zoomedVideo")) {
if (
gallery
.querySelector("#zoomedVideo")
.querySelector("audio")
) {
gallery
.querySelector("#zoomedVideo")
.querySelector("audio")
.pause();
}
gallery.removeChild(
gallery.querySelector("#zoomedVideo")
);
} else if (gallery.querySelector("#zoomedImage")) {
gallery.removeChild(
gallery.querySelector("#zoomedImage")
);
} else {
image.style = "";
// image.src = thumbnailUrl;
setStyles(image, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
});
}
// check if it has a video
const video = previousCell?.querySelector("video");
if (video) {
const video = previousCell
.querySelector("video")
.cloneNode(true);
video.id = "zoomedVideo";
video.style = "";
setStyles(video, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "10000",
height: "80%",
width: "80%",
objectFit: "contain",
cursor: "pointer",
preload: "auto",
});
gallery.appendChild(video);
// check if there is an audio element
let audio = previousCell.querySelector("audio");
if (audio) {
audio = audio.cloneNode(true);
// same event listeners as the video
video.onplay = (event) => {
audio.play();
};
video.onpause = (event) => {
audio.pause();
};
let lastVideoTime = 0;
video.addEventListener("timeupdate", () => {
if (
Math.abs(
video.currentTime - lastVideoTime
) >= 2
) {
audio.currentTime = video.currentTime;
lastVideoTime = video.currentTime;
}
lastVideoTime = video.currentTime;
});
video.appendChild(audio);
}
} else {
// if it doesn't have a video, it must have an image
const originalImage =
previousCell.querySelector("img");
const currentImage =
originalImage.cloneNode(true);
currentImage.id = "zoomedImage";
currentImage.style = "";
currentImage.src =
currentImage.getAttribute("actualSrc");
originalImage.src =
originalImage.getAttribute("actualSrc");
setStyles(currentImage, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "10000",
height: "80%",
width: "80%",
objectFit: "contain",
cursor: "pointer",
});
gallery.appendChild(currentImage);
currentImage.addEventListener("click", () => {
gallery.removeChild(currentImage);
gallery.removeChild(background);
document.removeEventListener(
"keydown",
keybindHandler
);
});
let audio = previousCell.querySelector("audio");
if (audio) {
audio = audio.cloneNode(true);
currentImage.appendChild(audio);
// event listeners when hovering over the image
currentImage.addEventListener(
"mouseenter",
() => {
audio.play();
}
);
currentImage.addEventListener(
"mouseleave",
() => {
audio.pause();
}
);
}
}
if (previousCell) {
currentCell = previousCell;
buttonContainer.setAttribute(
"mediaLink",
previousCell.querySelector("img").src
);
currentImageNumber -= 1;
imageNumber.textContent = `${currentImageNumber}/${imageTotal}`;
// filename of the video if it has one, otherwise the filename of the image
imageTitle.textContent = video
? video.getAttribute("fileName")
: previousCell
.querySelector("img")
.getAttribute("fileName");
// update view post button link
let previousMedia = video || previousCell.querySelector("img");
let previousBoard = previousMedia.getAttribute("board");
let previousThreadID = previousMedia.getAttribute("threadID");
let previousPostID = previousMedia.getAttribute("postID");
viewPostButton.href = `https://boards.4chan.org/${previousBoard}/thread/${previousThreadID}#p${previousPostID}`;
// update the download button links
downloadButton.href = previousMedia.src;
if (previousCell.querySelector("audio")) {
// updating audio button download link
audioDownloadButton.href =
previousCell.querySelector("audio").src;
audioDownloadButton.download = previousCell
.querySelector("audio")
.src.split("/")
.pop();
audioDownloadButton.style.display = "block";
// updating encoded soundpost button link
encodedSoundPostButton.href = previousCell.querySelector("audio")
.getAttribute("encodedSoundPostLink");
encodedSoundPostButton.style.display = "block";
} else {
audioDownloadButton.style.display = "none";
encodedSoundPostButton.style.display = "none";
}
}
}
} else if (event.key === "ArrowRight") {
// get the next cell in the grid
const nextCell = currentCell.nextElementSibling;
if (nextCell) {
if (gallery.querySelector("#zoomedVideo")) {
if (
gallery
.querySelector("#zoomedVideo")
.querySelector("audio")
) {
gallery
.querySelector("#zoomedVideo")
.querySelector("audio")
.pause();
}
gallery.removeChild(
gallery.querySelector("#zoomedVideo")
);
// ("removed video");
} else if (gallery.querySelector("#zoomedImage")) {
gallery.removeChild(
gallery.querySelector("#zoomedImage")
);
// ("removed image");
} else {
image.style = "";
setStyles(image, {
maxWidth: "100%",
maxHeight: "200px",
objectFit: "contain",
});
}
// check if it has a video
const video = nextCell?.querySelector("video");
if (video) {
const video = nextCell
.querySelector("video")
.cloneNode(true);
video.id = "zoomedVideo";
video.style = "";
setStyles(video, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "10000",
height: "80%",
width: "80%",
objectFit: "contain",
cursor: "pointer",
preload: "auto",
});
// check if there is an audio element
let audio = nextCell.querySelector("audio");
if (audio) {
audio = audio.cloneNode(true);
// same event listeners as the video
video.onplay = (event) => {
audio.play();
};
video.onpause = (event) => {
audio.pause();
};
let lastVideoTime = 0;
video.addEventListener("timeupdate", () => {
if (
Math.abs(
video.currentTime - lastVideoTime
) >= 2
) {
audio.currentTime = video.currentTime;
lastVideoTime = video.currentTime;
}
lastVideoTime = video.currentTime;
});
video.appendChild(audio);
}
gallery.appendChild(video);
} else {
const originalImage =
nextCell.querySelector("img");
const currentImage =
originalImage.cloneNode(true);
currentImage.id = "zoomedImage";
currentImage.style = "";
currentImage.src =
currentImage.getAttribute("actualSrc");
originalImage.src =
originalImage.getAttribute("actualSrc");
setStyles(currentImage, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "10000",
height: "80%",
width: "80%",
objectFit: "contain",
cursor: "pointer",
});
gallery.appendChild(currentImage);
currentImage.addEventListener("click", () => {
gallery.removeChild(currentImage);
gallery.removeChild(background);
document.removeEventListener(
"keydown",
keybindHandler
);
});
let audio = nextCell.querySelector("audio");
if (audio) {
audio = nextCell
.querySelector("audio")
.cloneNode(true);
currentImage.appendChild(audio);
currentImage.addEventListener(
"mouseenter",
() => {
audio.play();
}
);
currentImage.addEventListener(
"mouseleave",
() => {
audio.pause();
}
);
}
}
if (nextCell) {
currentCell = nextCell;
buttonContainer.setAttribute(
"mediaLink",
nextCell.querySelector("img").src
);
currentImageNumber += 1;
imageNumber.textContent = `${currentImageNumber}/${imageTotal}`;
// filename of the video if it has one, otherwise the filename of the image
imageTitle.textContent = video
? video.getAttribute("fileName")
: nextCell
.querySelector("img")
.getAttribute("fileName");
// update view post button link
let nextMedia = video || nextCell.querySelector("img");
let nextBoard = nextMedia.getAttribute("board");
let nextThreadID = nextMedia.getAttribute("threadID");
let nextPostID = nextMedia.getAttribute("postID");
viewPostButton.href = `https://boards.4chan.org/${nextBoard}/thread/${nextThreadID}#p${nextPostID}`;
// update the download button links
downloadButton.href = nextMedia.src;
if (nextCell.querySelector("audio")) {
audioDownloadButton.href =
nextCell.querySelector("audio").src;
audioDownloadButton.download = nextCell
.querySelector("audio")
.src.split("/")
.pop();
audioDownloadButton.style.display = "block";
encodedSoundPostButton.href = nextCell.querySelector("audio")
.getAttribute("encodedSoundPostLink");
encodedSoundPostButton.style.display = "block";
} else {
audioDownloadButton.style.display = "none";
encodedSoundPostButton.style.display = "none";
}
}
}
}
};
document.addEventListener("keydown", keybindHandler);
image.addEventListener(
"click",
() => {
image.style = "";
// image.src = thumbnailUrl;
setStyles(image, {
maxWidth: "99%",
maxHeight: "199px",
objectFit: "contain",
});
if (gallery.querySelector("#darkenBackground")) {
gallery.removeChild(background);
}
document.removeEventListener(
"keydown",
keybindHandler
);
image.addEventListener("click", zoomImage, {
once: true,
});
},
{ once: true }
);
};
image.addEventListener("click", zoomImage, { once: true });
image.title = comment.innerText;
image.loading = "lazy";
if (soundLink) {
const audio = document.createElement("audio");
audio.src = decodeURIComponent(
soundLink[1].startsWith("http")
? soundLink[1]
: `https://${soundLink[1]}`
);
audio.loop = true;
// set the attribute to the audio element with the encoded soundpost link
audio.setAttribute(
"encodedSoundPostLink",
encodedSoundPostLink
);
imageContainer.appendChild(audio);
image.addEventListener("mouseenter", () => {
audio.play();
});
image.addEventListener("mouseleave", () => {
audio.pause();
});
const playPauseButton = document.createElement("button");
playPauseButton.textContent = "Play/Pause";
setStyles(playPauseButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
playPauseButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
});
buttonDiv.appendChild(playPauseButton);
}
imageContainer.appendChild(image);
cell.appendChild(imageContainer);
} else {
return; // Skip non-video and non-image posts
}
// Add button that scrolls to the post in the thread
const viewPostButton = document.createElement("button");
viewPostButton.textContent = "View Post";
setStyles(viewPostButton, {
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "5px 10px",
borderRadius: "3px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
viewPostButton.addEventListener("click", () => {
// post id example: "pc77515440"
window.location.href = postURL + "#" + post.id;
document.body.removeChild(gallery);
});
buttonDiv.appendChild(viewPostButton);
cell.appendChild(buttonDiv);
gridContainer.appendChild(cell);
}
}
});
});
};
// only load the fake image in the first thread
loadPostsFromThread(checkedThreads[0], addFakeImage);
// load the rest of the threads with no fake image
checkedThreads.slice(1).forEach((thread) => {
loadPostsFromThread(thread, false);
});
};
loadPosts(mode);
gallery.appendChild(gridContainer);
const closeButton = document.createElement("button");
closeButton.textContent = "Close";
closeButton.id = "closeGallery";
setStyles(closeButton, {
position: "absolute",
bottom: "10px",
right: "10px",
zIndex: "10000",
backgroundColor: "#1c1c1c",
color: "#d9d9d9",
padding: "10px 20px",
borderRadius: "5px",
border: "none",
cursor: "pointer",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.3)",
});
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
// (`Last scroll position: ${lastScrollPosition} px`);
gridContainer.addEventListener("scroll", () => {
lastScrollPosition = gridContainer.scrollTop;
// (`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(() => {
if (gallerySize.width > 0 && gallerySize.height > 0) {
gridContainer.style.width = `${gallerySize.width}px`;
gridContainer.style.height = `${gallerySize.height}px`;
}
// (`Restored scroll position: ${lastScrollPosition} px`);
gridContainer.scrollTop = lastScrollPosition;
}, 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) {
// adds the category to thead
const thead = document.querySelector(".flashListing thead tr");
const checkboxCell = document.createElement("td");
checkboxCell.className = "postblock";
checkboxCell.textContent = "Selected";
thead.insertBefore(checkboxCell, thead.firstChild);
// 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);
});
}
};
// Use the "i" key to open and close the gallery/grid
document.addEventListener("keydown", (event) => {
if (event.key === "i") {
// Prevent the gallery from opening when typing in an input or textarea
if (
event.target.tagName == "INPUT" ||
event.target.tagName == "TEXTAREA"
) {
return;
}
if (document.querySelector("#imageGallery")) {
document.body.removeChild(document.querySelector("#imageGallery"));
} else {
if (document.querySelector("#openImageGallery")) {
document.querySelector("#openImageGallery").click();
}
}
}
});
loadButton();
("4chan Gallery loaded successfully!");
})();