// ==UserScript==
// @name 8chan gallery script
// @namespace https://greatest.deepsurf.us/en/users/1461449
// @match https://8chan.moe/*/res/*
// @match https://8chan.se/*/res/*
// @grant GM_setValue
// @grant GM_getValue
// @version 1.7
// @description Gallery viewer for 8chan threads
// @license MIT
// ==/UserScript==
function addCSS(css) {
const style = document.createElement('style');
document.head.append(style);
style.textContent = css;
return style;
}
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;
}
});
if (!options.exists) {
options.exists = true;
options.muteVideo = false;
options.volume = 0.3
}
if (options.muteVideo) {
options.volume = 0;
}
class Post {
constructor(element, thread) {
this.element = element;
this.id = element.id;
this.replies = [];
if (thread) {
thread.posts.push(this);
element.querySelectorAll('.postInfo > .panelBacklinks > a').forEach(link => {
const reply = link.textContent.replace(/\D/g, '');
this.replies.push(reply);
});
}
const details = element.querySelectorAll('details');
this.files = Array.from(details).map(d => {
const imgLink = d.querySelector('a.imgLink');
if (imgLink) {
return {
url: imgLink.href,
thumbnail: imgLink.querySelector('img')?.src,
name: d.querySelector('.originalNameLink')?.download || '',
video: d.querySelector('video') !== null,
parentPost: this
};
}
}).filter(Boolean);
}
hidden() {
return this.element.querySelector(".unhideButton") !== null;
}
}
class Thread extends Post {
static all = [];
constructor(opEl) {
super(opEl, null);
this.posts = [this];
Thread.all.push(this);
}
}
class Gallery {
constructor() {
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 = [];
document.addEventListener('keyup', e => {
if (e.key === 'g') {
this.visible ? this.remove() : this.show();
} else if (e.key === 'Escape' && this.visible) {
this.remove();
}
});
document.addEventListener('keydown', e => {
if (!this.visible) return;
switch (e.key) {
case 'ArrowLeft':
this.showIndex((this.currentIndex - 1 + this.filteredMedia.length) % this.filteredMedia.length);
break;
case 'ArrowRight':
this.showIndex((this.currentIndex + 1) % this.filteredMedia.length);
break;
case 'r':
if (!e.ctrlKey) this.rotate();
break;
}
});
}
mediaItems() {
return this.thread.posts.flatMap(p => (p.files || []).filter(f => !p.hidden() && (f.video ? this.showVideos : this.showImages)));
}
show() {
if (!this.container) this.buildUI();
const op = document.querySelector('div.opCell .innerOP');
if (!op) return;
this.thread = new Thread(op);
document.querySelectorAll('div.opCell .divPosts > div').forEach(el => new Post(el, this.thread));
document.body.append(this.container);
this.visible = true;
this.updatePreviews();
this.currentIndex = this.getClosestPost();
this.showIndex(this.currentIndex);
}
getClosestPost() {
const centerY = window.innerHeight / 2;
let best = { idx: 0, dist: Infinity };
this.mediaItems().forEach((media, i) => {
const rect = media.parentPost?.element.getBoundingClientRect();
if (rect) {
const postCenter = rect.top + rect.height / 2;
const dist = Math.abs(postCenter - centerY);
if (dist < best.dist) best = { idx: i, dist };
}
});
return best.idx;
}
remove() {
if (this.container) this.container.remove();
this.visible = false;
this.mediaEl.onmouseout();
}
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) {
e.preventDefault();
mediaEl.volume += (e.deltaY < 0 ? 0.02 : -0.02);
mediaEl.volume = Math.min(Math.max(mediaEl.volume, 0), 1);
}
mediaEl.onmouseover = () => {
window.addEventListener(wheelEvent, handleScroll, wheelOpt);
};
mediaEl.onmouseout = () => {
window.removeEventListener(wheelEvent, handleScroll, wheelOpt);
};
}
buildUI() {
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
});
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", borderRadius: "3px", zIndex: "59"
});
this.filenameLabel = document.createElement("a");
this.filenameLabel.classList.add("gallery-label");
this.filenameLabel.id = "gallery-label-filename"
this.filenameLabel.style.color = "white";
this.indexLabel = document.createElement("a");
this.indexLabel.classList.add("gallery-label");
this.indexLabel.id = "gallery-label-index"
this.indexLabel.style.color = "white";
infoLabels.append(this.indexLabel, this.filenameLabel);
this.filterLabels = document.createElement("div");
Object.assign(this.filterLabels.style, {
position: "absolute", display: "flex", flexDirection: "column",
alignItems: "flex-end", top: "5px", right: "5px", borderRadius: "3px", zIndex: "59"
});
const createFilterLabel = (id, text, toggleFn) => {
const label = document.createElement("a");
label.id = id;
label.textContent = text;
label.classList.add("gallery-label");
label.style.color = "white";
label.style.cursor = 'pointer';
label.addEventListener('click', toggleFn);
return label;
};
const imageLabel = createFilterLabel("gallery-label-image", "Images", () => {
this.showImages = !this.showImages;
imageLabel.style.color = this.showImages ? "white" : "red";
const currentMedia = this.filteredMedia[this.currentIndex];
this.updatePreviews();
if (this.filteredMedia) {
let newIndex = this.filteredMedia.indexOf(this.filteredMedia.find(el => el == currentMedia));
if (newIndex == -1) {
newIndex = this.getClosestPost();
this.showIndex(newIndex)
} else {
this.showIndex(newIndex, false);
}
}
});
const videoLabel = createFilterLabel("gallery-label-video", "Videos", () => {
this.showVideos = !this.showVideos;
videoLabel.style.color = this.showVideos ? "white" : "red";
const currentMedia = this.filteredMedia[this.currentIndex];
this.updatePreviews();
if (this.filteredMedia) {
let newIndex = this.filteredMedia.indexOf(this.filteredMedia.find(el => el == currentMedia));
if (newIndex == -1) {
newIndex = this.getClosestPost();
}
this.showIndex(newIndex)
}
});
this.filterLabels.append(imageLabel, videoLabel);
this.labelsDiv.append(this.filterLabels, infoLabels);
this.viewer.append(this.labelsDiv);
this.mediaEl = document.createElement('video');
this.mediaEl.controls = true;
this.mediaEl.loop = true;
this.mediaEl.style.maxWidth = '90vw';
this.mediaEl.style.height = '94vh';
this.mediaEl.style.objectFit = 'contain';
this.mediaEl.addEventListener('volumechange', () => { options.volume = this.mediaEl.volume; });
this.addMediaScroll(this.mediaEl);
this.mediaContainer = document.createElement('div');
this.mediaContainer.append(this.mediaEl);
this.viewer.append(this.mediaContainer);
this.sidebar = document.createElement('div');
this.sidebar.id = 'gallery-sidebar';
this.sidebar.tabIndex = 0;
this.sidebar.addEventListener('wheel', (e) => {
const delta = e.deltaY;
const atTop = this.sidebar.scrollTop === 0;
const atBottom = this.sidebar.scrollHeight - this.sidebar.clientHeight === this.sidebar.scrollTop;
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
e.preventDefault();
}
}, { passive: false });
Object.assign(this.sidebar.style, {
width: '150px', background: 'rgba(0,0,0,0.6)', padding: '5px', overflowY: 'auto'
});
this.previewContainer = document.createElement('div');
this.sidebar.append(this.previewContainer);
this.container.append(this.viewer, this.sidebar);
addCSS(`
/* Explicit color for filter labels to ensure visibility */
#gallery-label-image, #gallery-label-video, #gallery-label-mute { color: white; }
#gallery-label-image:hover, #gallery-label-video:hover, #gallery-label-mute:hover, #gallery-label-filename:hover { background: rgba(50, 50, 50, 0.8) !important; }
/* Ensure labels are above media elements */
#gallery-labels { z-index: 10; }
#gallery-sidebar { scrollbar-width: thin; scrollbar-color: #555 #222; }
#gallery-sidebar::-webkit-scrollbar { width: 8px; }
#gallery-sidebar::-webkit-scrollbar-track { background: #222; }
#gallery-sidebar::-webkit-scrollbar-thumb { background-color: #555; border-radius: 4px; border: 2px solid #222; }
.gallery-thumb { display: block; width: calc(100% - 10px); margin: 0 auto 8px auto; cursor: pointer; opacity: 0.6; transition: opacity 0.2s ease-in-out, border-color 0.2s ease-in-out; border: 2px solid transparent; border-radius: 3px; box-sizing: border-box; background-color: #111; /* Add background for missing thumbs */ min-height: 50px; /* Min height for missing thumbs */ }
.gallery-thumb:hover { opacity: 0.85; }
.gallery-thumb.selected { opacity: 1; border: 2px solid #00baff; }
#gallery-labels { pointer-events: none; }
#gallery-labels > div { position: absolute; right: 10px; display: flex; flex-direction: column; align-items: flex-end; pointer-events: auto; }
#gallery-labels-info { bottom: 10px; }
#gallery-labels-filters { top: 10px; }
.gallery-label { display: block; padding: 3px 6px; background: rgba(0, 0, 0, 0.7) !important; margin-bottom: 4px; font-size: 0.9em; text-decoration: none; border-radius: 3px; transition: background-color 0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
.gallery-filter-label.gallery-label { cursor: pointer; }
#gallery-label-index.gallery-label { user-select: none; cursor: pointer; /* Make index clickable to scroll */ }
#gallery-label-index.gallery-label:hover {cursor: unset !important}
`);
}
updatePreviews() {
this.previewContainer.innerHTML = '';
this.filteredMedia = this.mediaItems();
this.previews = [];
this.filteredMedia.forEach((media, idx) => {
const thumb = document.createElement('img');
thumb.src = media.thumbnail;
thumb.title = media.name;
thumb.className = 'gallery-thumb';
thumb.addEventListener('click', () => this.showIndex(idx));
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.appendChild(thumb);
const replyLen = media.parentPost?.replies?.length || 0;
if (replyLen > 0) {
const replyCount = document.createElement('div');
replyCount.textContent = replyLen;
Object.assign(replyCount.style, {
position: 'absolute',
top: '3px',
right: '3px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '2px 5px',
fontSize: '13px',
borderRadius: '3px',
pointerEvents: 'none'
});
wrapper.appendChild(replyCount);
}
this.previewContainer.append(wrapper);
this.previews.push(thumb);
});
}
updateLabels(media) {
this.filenameLabel.textContent = media.name;
this.filenameLabel.setAttribute("href", media.url);
this.indexLabel.textContent = (this.currentIndex + 1) + " / " + this.filteredMedia.length;
}
showIndex(idx, updateMedia = true) {
this.currentIndex = idx;
this.previews.forEach((t, i) => t.classList.toggle('selected', i === idx));
this.previews[idx].scrollIntoView({ behavior: 'auto', block: 'center' });
const media = this.filteredMedia[idx];
Array.from(this.mediaContainer.querySelectorAll('img')).forEach(img => img.remove());
this.updateLabels(media);
if (updateMedia) {
if (media.video) {
this.mediaEl.style.display = '';
this.mediaEl.src = media.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 = media.url;
img.style.maxWidth = '90vw';
img.style.height = '98vh';
img.style.objectFit = 'contain';
this.mediaContainer.append(img);
}
this.rotation = 0;
this.mediaContainer.style.transform = 'rotate(0deg)';
media.parentPost?.element.scrollIntoView({ behavior: 'auto', block: 'center' });
}
}
rotate() {
this.rotation = (this.rotation + 90) % 360;
this.mediaContainer.style.transform = `rotate(${this.rotation}deg)`;
}
}
(() => {
const op = document.querySelector('div.opCell .innerOP');
if (!op) return;
new Gallery();
})();