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