8chan gallery script

Gallery viewer for 8chan threads

Per 23-04-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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
// ==/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();
})();