8chan gallery script

Gallery viewer for 8chan threads

Versione datata 23/04/2025. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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