// ==UserScript== // @name 8chan gallery script // @namespace https://greasyfork.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 // @downloadURL none // ==/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(); })();