// ==UserScript==
// @name 8chan gallery script
// @namespace https://greasyfork.dpdns.org/en/users/1461449
// @match https://8chan.moe/*/res/*
// @match https://8chan.se/*/res/*
// @grant GM_setValue
// @grant GM_getValue
// @version 1.2
// @description Gallery viewer for 8chan threads
// @license MIT
// ==/UserScript==
/* Utility: inject CSS */
function addCSS(css) {
const style = document.createElement('style');
document.head.append(style);
style.textContent = css;
return style;
}
/* User settings proxies */
const options = new Proxy({}, {
get: (_, prop) => {
if (prop == "volume") {
let e = parseFloat(localStorage.getItem('8chan-volume'));
return isNaN(e) ? 0 : e
} else {
return GM_getValue(prop)
}
},
set: (_, prop, value) => {
prop == "volume" ? localStorage.setItem('8chan-volume', value) : GM_setValue(prop, value);
return true;
}
})
/* Defaults on first run */
if (!options.exists) {
options.exists = true;
options.muteVideo = false;
}
if (options.muteVideo) {
options.volume = 0;
} else if (options.volume === 0) {
options.volume = 0.3;
}
class Post {
static all = [];
constructor(element, thread) {
this.element = element;
this.id = element.id;
this.replies = [];
if (thread) {
this.thread = thread;
thread.posts.push(this);
element.querySelectorAll('.panelBacklinks > a').forEach(link => {
const target = link.textContent.replace(/\D/g, '');
if (target === thread.id) {
thread.replies.push(this);
} else {
const quoted = thread.posts.find(p => p.id === target);
if (quoted) quoted.replies.push(this);
}
});
}
const details = element.querySelector('details');
if (details) {
const imgLink = details.querySelector('a.imgLink');
if (imgLink) {
this.file = {
url: imgLink.href,
thumbnail: imgLink.querySelector('img').src,
name: details.querySelector('.originalNameLink').download,
video: details.querySelector("video") !== null
};
}
}
Post.all.push(this);
}
hidden() {
return this.element.querySelector(".unhideButton") !== null;
}
}
class Thread extends Post {
static all = [];
constructor(opEl) {
super(opEl, null);
this.posts = [];
Thread.all.push(this);
}
}
class Gallery {
constructor() {
this.posts = () => Post.all.filter(p => p.file);
this.visible = false;
this.showImages = true;
this.showVideos = true;
this.currentIndex = 0;
this.rotation = 0;
this.container = null;
this.viewer = null;
this.mediaEl = null;
this.sidebar = null;
this.previewContainer = null;
this.previews = [];
// Toggle gallery: 'g' to open/close, 'Escape' to close
document.addEventListener('keyup', e => {
if (e.key === 'g') {
this.visible ? this.remove() : this.show();
} else if (e.key === 'Escape' && this.visible) {
this.remove();
}
});
// Navigation & rotation
document.addEventListener('keydown', e => {
if (!this.visible) return;
switch (e.key) {
case 'ArrowLeft':
this.showIndex((this.currentIndex - 1 + this.filteredPosts.length) % this.filteredPosts.length);
break;
case 'ArrowRight':
this.showIndex((this.currentIndex + 1) % this.filteredPosts.length);
break;
case 'r':
if (!e.ctrlKey) this.rotate();
break;
}
});
}
show() {
if (!this.container) this.buildUI();
document.body.append(this.container);
this.visible = true;
this.updatePreviews();
this.currentIndex = this.getClosestPost();
this.showIndex(this.currentIndex);
}
getClosestPost() {
let best = { idx: 0, dist: Infinity };
this.filteredPosts.forEach((p, i) => {
const rect = p.element.getBoundingClientRect();
const d = Math.abs(rect.top);
if (d < best.dist) { best = { idx: i, dist: d }; }
});
return best.idx;
}
remove() {
if (this.container) this.container.remove();
this.visible = false;
}
addMediaScroll(mediaEl) {
let supportsPassive = false;
try {
window.addEventListener("test", null, Object.defineProperty({}, 'passive', {
get: function () { supportsPassive = true; }
}));
} catch (e) { }
let wheelOpt = supportsPassive ? { passive: false } : false;
let wheelEvent = 'onwheel' in document.createElement('div') ? 'wheel' : 'mousewheel';
function handleScroll(e) {
function ScrollDirectionIsUp(event) {
if (event.wheelDelta) {
return event.wheelDelta > 0;
}
return event.deltaY < 0;
}
e.preventDefault();
mediaEl.volume = ScrollDirectionIsUp(e) ? (mediaEl.volume + 0.02 > 1 ? 1 : (mediaEl.volume + 0.02)) : (mediaEl.volume - 0.02 < 0 ? 0 : (mediaEl.volume - 0.02))
}
mediaEl.onmouseover = () => {
window.addEventListener(wheelEvent, handleScroll, wheelOpt);
};
mediaEl.onmouseout = () => {
window.removeEventListener(wheelEvent, handleScroll, wheelOpt);
};
}
buildUI() {
// Main overlay
this.container = document.createElement('div');
Object.assign(this.container.style, {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.7)', display: 'flex', zIndex: 9999
});
// Viewer
this.viewer = document.createElement('div');
Object.assign(this.viewer.style, {
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative'
});
this.viewer.addEventListener('click', (e) => {
if (e.target === this.viewer) {
this.remove();
}
});
this.labelsDiv = document.createElement("div");
this.labelsDiv.id = "gallery-labels";
let infoLabels = document.createElement("div");
infoLabels.setAttribute("id", "gallery-labels-info");
Object.assign(infoLabels.style, {
position: "absolute",
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
bottom: "5px",
right: "5px", // <- changed from 150px
borderRadius: "3px",
zIndex: "59"
});
this.filenameLabel = document.createElement("a");
this.filenameLabel.id = "gallery-label-filename";
this.filenameLabel.classList.add("gallery-label");
this.filenameLabel.style.color = "white"
this.indexLabel = document.createElement("a");
this.indexLabel.id = "gallery-label-index";
this.indexLabel.classList.add("gallery-label");
this.indexLabel.style.color = "white"
infoLabels.append(this.indexLabel);
infoLabels.append(this.filenameLabel);
this.filterLabels = document.createElement("div");
this.filterLabels.setAttribute("id", "gallery-labels-filters");
this.filterLabels.style.position = "absolute";
this.filterLabels.style.display = "flex";
this.filterLabels.style.flexDirection = "column";
this.filterLabels.style.alignItems = "flex-end";
this.filterLabels.style.top = "5px";
this.filterLabels.style.right = "5px";
this.filterLabels.style.borderRadius = "3px";
this.filterLabels.style.zIndex = "59";
let imageLabel = document.createElement("a");
imageLabel.id = "gallery-label-image";
imageLabel.style.color = "white"
imageLabel.classList.add("gallery-label");
imageLabel.textContent = "Images";
let videoLabel = document.createElement("a");
videoLabel.id = "gallery-label-video";
videoLabel.style.color = "white"
videoLabel.classList.add("gallery-label");
videoLabel.textContent = "Videos";
imageLabel.addEventListener('click', () => {
this.showImages = !this.showImages;
imageLabel.style.color = this.showImages ? "white" : "red";
let post;
if (this.filteredPosts) {
post = this.filteredPosts[this.currentIndex];
}
this.updatePreviews();
if (this.filteredPosts) {
let newIndex = this.filteredPosts.indexOf(this.filteredPosts.find(el => el.id == post.id));
if (newIndex == -1) {
newIndex = this.getClosestPost();
}
this.showIndex(newIndex)
}
});
videoLabel.addEventListener('click', () => {
this.showVideos = !this.showVideos;
videoLabel.style.color = this.showVideos ? "white" : "red";
let post;
if (this.filteredPosts) {
post = this.filteredPosts[this.currentIndex];
}
this.updatePreviews();
if (this.filteredPosts) {
let newIndex = this.filteredPosts.indexOf(this.filteredPosts.find(el => el.id == post.id));
if (newIndex == -1) {
newIndex = this.getClosestPost();
}
this.showIndex(newIndex)
}
});
this.filterLabels.append(imageLabel);
this.filterLabels.append(videoLabel);
this.labelsDiv.append(this.filterLabels);
this.labelsDiv.append(infoLabels);
this.viewer.append(this.labelsDiv);
this.mediaEl = document.createElement('video');
this.mediaEl.controls = true;
this.mediaEl.loop = true;
this.mediaEl.style.maxWidth = '98%';
this.mediaEl.style.maxHeight = '96%';
this.mediaEl.addEventListener('volumechange', () => { options.volume = this.mediaEl.volume; });
this.addMediaScroll(this.mediaEl);
this.viewer.append(this.mediaEl);
// Sidebar (thumbnails + filters)
this.sidebar = document.createElement('div');
Object.assign(this.sidebar.style, {
width: '150px', background: 'rgba(0,0,0,0.6)', padding: '5px', overflowY: 'auto'
});
// Filter panel at top
const filterPanel = document.createElement('div');
Object.assign(filterPanel.style, { marginBottom: '10px', textAlign: 'center' });
this.sidebar.append(filterPanel);
// Thumbnails container
this.previewContainer = document.createElement('div');
this.sidebar.append(this.previewContainer);
// Assemble
this.container.append(this.viewer, this.sidebar);
// Thumbnail highlight CSS
addCSS(`
.gallery-thumb { width: 100%; margin-bottom: 8px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; }
.gallery-thumb.selected { opacity: 1; border: 2px solid #00baff; }
.gallery-label {
padding: 2px;
background: rgba(0, 0, 0, 0.6) !important;
margin-bottom: 3px;
}
.gallery-label:hover {
color: gray !important
}
`);
}
updatePreviews() {
this.previewContainer.innerHTML = '';
this.filteredPosts = this.posts().filter(p => !p.hidden() && (p.file.video ? this.showVideos : this.showImages));
this.previews = [];
this.filteredPosts.forEach((post, idx) => {
const thumb = document.createElement('img');
thumb.src = post.file.thumbnail;
thumb.title = post.file.name;
thumb.className = 'gallery-thumb';
thumb.addEventListener('click', () => this.showIndex(idx));
this.previewContainer.append(thumb);
this.previews.push(thumb);
});
}
updateLabels() {
const post = this.filteredPosts[this.currentIndex];
this.filenameLabel.textContent = post.file.name;
this.filenameLabel.setAttribute("href", post.file.url);
this.indexLabel.textContent = (this.filteredPosts.indexOf(this.filteredPosts.find(el => el.id == post.id)) + 1) + " / " + this.filteredPosts.length;
}
showIndex(idx) {
this.currentIndex = idx;
this.previews.forEach((t, i) => t.classList.toggle('selected', i === idx));
// Auto-scroll thumbnail into view
this.previews[idx].scrollIntoView({ behavior: 'auto', block: 'center' });
const post = this.filteredPosts[idx];
// Remove old images
Array.from(this.viewer.querySelectorAll('img')).forEach(img => img.remove());
this.updateLabels();
if (post.file.video) {
this.mediaEl.style.display = '';
this.mediaEl.src = post.file.url;
this.mediaEl.volume = options.volume;
this.mediaEl.play().catch(() => { });
} else {
this.mediaEl.pause();
this.mediaEl.style.display = 'none';
const img = document.createElement('img');
img.src = post.file.url;
img.style.maxWidth = '98%';
img.style.maxHeight = '96%';
this.viewer.append(img);
}
this.rotation = 0;
this.mediaEl.style.transform = 'rotate(0deg)';
post.element.scrollIntoView({ behavior: 'auto', block: 'center' });
}
rotate() {
this.rotation = (this.rotation + 90) % 360;
this.mediaEl.style.transform = `rotate(${this.rotation}deg)`;
}
}
/* Initialization */
(() => {
const op = document.querySelector('div.opCell .innerOP');
if (!op) return;
const thread = new Thread(op);
document.querySelectorAll('div.opCell .divPosts > div').forEach(el => {
new Post(el, thread);
});
new Gallery();
})();