8chan gallery script

Gallery viewer for 8chan threads

Από την 23/04/2025. Δείτε την τελευταία έκδοση.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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