8chan gallery script

Gallery viewer for 8chan threads

От 23.04.2025. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

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