4chan-masonry

View all media (images+videos) from a 4chan thread in a masonry grid layout.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         4chan-masonry
// @namespace    0000xFFFF
// @version      1.3.3
// @description  View all media (images+videos) from a 4chan thread in a masonry grid layout.
// @author       0000xFFFF
// @license      MIT
// @match        *://boards.4chan.org/*/thread/*
// @match        *://boards.4channel.org/*/thread/*
// @grant        GM_addStyle
// @icon         data:image/ico;base64,AAABAAEAEBAAAAEAIACaAAAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAGFJREFUeJxjYICA/0iYEMBQ+z/tjDEcEzAEp1piDCFOM7EGYBiCxW/EihH2KxFhg10zzCZSDMHQTKohFBtAkRdQQhtPIGI1CJ9CrAYjG0JxUqaOAcg0IQOwqf2PRuMDKGoBmcLsrWcgpCUAAAAASUVORK5CYII=
// ==/UserScript==

const GRID_ROWS_MIN = 1;
const GRID_ROWS_MAX = 15;
const GRID_ROWS_DEFAULT = 4;
const CONCURRENT_LOADS_IMAGE = 3;
const CONCURRENT_LOADS_VIDEO = 1;
const LOAD_DELAY_IMAGE = 10;
const LOAD_DELAY_VIDEO = 300;
const PRELOAD_VIEWPORT_BUFFER = 200; // Load images within 200px of viewport

function GM_addStyle(css) {
    const style = document.createElement("style");
    style.textContent = css;
    (document.head || document.documentElement).appendChild(style);
    return style;
}

// Usage

var MasonryCss = `

#fcm_overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    background: rgba(0,0,0,0.95);
    z-index: 10000;
    overflow: auto;
    box-sizing: border-box;
}

#fcm_topbar {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 15px 20px;
    background-color: rgba(0, 0, 0, 0.8);
    color: #fff;
    z-index: 999;
    transition: transform 0.3s ease-in-out;
    will-change: transform;
    backdrop-filter: blur(5px);
}

.fcm_slider_container {
    display: flex;
    align-items: center;
    gap: 15px;
}

.fcm_close {
    padding: 8px 12px;
    background: #d32f2f;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-size: 14px;
    font-weight: bold;
    transition: all 0.2s ease;
    min-width: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.fcm_close:hover {
    background: #b71c1c;
    transform: scale(1.05);
}

#fcm_masonry {
    display: block;
    gap: 10px;
    margin-top: 90px;
    column-gap: 5px;
}

.fcm_shortcut_4chanx {
    margin: 0 0 0 5px;
    padding: 0;
    cursor: pointer;
    justify-content: center;
}

.fcm_shortcut_4chanx img {
    width: 16px;
    height: 13px;
}

.fcm_button_regular {
    padding: 12px 18px;
    display: flex;
    gap: 5px;
    background: #2d5016;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    font-size: 14px;
    font-weight: bold;
    box-shadow: 0 4px 15px rgba(0,0,0,0.3);
    transition: all 0.3s ease;
    white-space: nowrap;
}

.fcm_button_regular:hover {
    background: #4a7c21;
    transform: translateY(-2px);
    box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}

.fcm_button_regular img {
    height: 15px;
}

.fcm_container {
    display: flex;
    margin: 15px 0 15px 0;
}

.fcm_grid_container {
    max-width: 97vw;
    margin: 0 auto;
}

.fcm_value_display {
    color: white;
    font-weight: bold;
    font-size: 16px;
    min-width: 20px;
}

.fcm_media_wrapper {
    position: relative;
    overflow: hidden;
    border-radius: 8px;
    cursor: pointer;
    transition: transform 0.1s ease, box-shadow 0.1s ease;
}

.fcm_media_wrapper:hover {
    transform: scale(1.05);
    box-shadow: 0 8px 25px rgba(0,0,0,0.6);
    z-index: 100;
}

.fcm_media_img,
.fcm_media_thumb,
.fcm_media_video {
    width: 100%;
    display: block;
    object-fit: cover;
    cursor: pointer;
}

.fcm_media_loading, .fcm_media_thumb {

}

.fcm_media_img {
    object-fit: contain;
    transition: opacity 0.3s ease;
}

.fcm_media_video {
    object-fit: contain;
    display: none;
}

.fcm_play_btn {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 50px;
    height: 50px;
    background: rgba(0,0,0,0.5);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 24px;
    color: white;
    pointer-events: none;
    opacity: 0.4;
}

.fcm_tooltip {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    background: rgba(0,0,0,0.8);
    color: white;
    padding: 8px;
    font-size: 12px;
    transform: translateY(100%);
    transition: transform 0.3s ease;
    word-break: break-all;
}

.fcm_media_wrapper:hover .fcm_tooltip {
    transform: translateY(0);
}

`;

GM_addStyle(MasonryCss);

const userscript_icon_1 = "data:image/ico;base64,AAABAAEAEBAAAAEAIACFAAAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAExJREFUeJxjYBhM4D8SJlnN/7QzxnCMwxCCavApIE0zIQNwGoLFj9jYhMIJ01ZiNVDNAEJeIMtWolxDUTRSzwBkmkg5VEPQaGLlyAcAWwCk9UAWSQAAAAAASUVORK5CYII="
const userscript_icon_2 = "data:image/ico;base64,AAABAAEAEBAAAAEAIACaAAAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAGFJREFUeJxjYICA/0iYEMBQ+z/tjDEcEzAEp1piDCFOM7EGYBiCxW/EihH2KxFhg10zzCZSDMHQTKohFBtAkRdQQhtPIGI1CJ9CrAYjG0JxUqaOAcg0IQOwqf2PRuMDKGoBmcLsrWcgpCUAAAAASUVORK5CYII=";
const userscript_icon_3 = "data:image/ico;base64,AAABAAEACggAAAEAIABkAAAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAKAAAACAgGAAAAwPputgAAACtJREFUeJxjYEAF/9EwVvA/7YwxCsamGF0Sq2IMRbgUE62QaKtJ8gzB4AEA82hZQXIdFlEAAAAASUVORK5CYII="


let gridRows = GRID_ROWS_DEFAULT;
let isGridOpen = false;
let gridOverlay = null;
let loadQueue = [];
let activeLoadsImage = 0;
let activeLoadsVideo = 0;
let preloadCache = new Map(); // Cache to avoid duplicate requests

// Rate limiting function
function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// Check if element is near viewport
function isNearViewport(element, buffer = PRELOAD_VIEWPORT_BUFFER) {
    const rect = element.getBoundingClientRect();
    const windowHeight = window.innerHeight;
    const windowWidth = window.innerWidth;

    return (
        rect.bottom >= -buffer &&
        rect.top <= windowHeight + buffer &&
        rect.right >= -buffer &&
        rect.left <= windowWidth + buffer
    );
}

// Queue-based image preloader
async function preloadMedia(url, priority = 'low', type = 'image') {
    if (preloadCache.has(url)) {
        return preloadCache.get(url);
    }

    return new Promise((resolve, reject) => {
        loadQueue.push({ url, resolve, reject, priority, type });
        processLoadQueue();
    });
}

// Process the load queue with rate limiting
async function processLoadQueue() {
    if ((activeLoadsImage >= CONCURRENT_LOADS_IMAGE || activeLoadsVideo >= CONCURRENT_LOADS_VIDEO) || loadQueue.length === 0) {
        return;
    }

    // Sort by priority (high priority first)
    loadQueue.sort((a, b) => {
        const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
        return priorityOrder[b.priority] - priorityOrder[a.priority];
    });

    const item = loadQueue.shift();

    let delayMs = 0;
    if (item.type === 'image') {
        activeLoadsImage++;
        delayMs = LOAD_DELAY_IMAGE;
    }
    else {
        activeLoadsVideo++;
        delayMs = LOAD_DELAY_VIDEO;
    }

    try {
        await delay(delayMs); // Rate limiting delay

        const result = await loadMedia(item.url, item.type);
        preloadCache.set(item.url, result);
        item.resolve(result);
    } catch (error) {
        console.warn(`Failed to load ${item.url}:`, error);
        item.reject(error);
    } finally {
        if (item.type === 'image') {
            activeLoadsImage--;
        }
        else {
            activeLoadsVideo--;
        }

        // Process next item in queue
        setTimeout(processLoadQueue, 50);
    }
}

function updateQueuePriority(url, newPriority) {
    const item = loadQueue.find(i => i.url === url);
    if (item) {
        item.priority = newPriority;
    }
}

function updatePriorities() {
    document.querySelectorAll('.fcm_media_img').forEach(img => {
        const url = img.dataset.fullUrl;

        if (!url) return; // Already loaded
        if (preloadCache.has(url)) return; // Already done

        const item = loadQueue.find(i => i.url === url);
        if (item) {
            item.priority = isNearViewport(img) ? 'medium' : 'low';
        }
    });
}

function throttle(fn, delay) {
    let lastCall = 0;
    let timeout;

    return function (...args) {
        const now = Date.now();

        if (now - lastCall < delay) {
            clearTimeout(timeout);
            timeout = setTimeout(() => {
                lastCall = Date.now();
                fn.apply(this, args);
            }, delay - (now - lastCall));
        } else {
            lastCall = now;
            fn.apply(this, args);
        }
    };
}

const throttledUpdatePriorities = throttle(updatePriorities, 800);
window.addEventListener('scroll', throttledUpdatePriorities);
window.addEventListener('resize', throttledUpdatePriorities);

// Actual media loading function
function loadMedia(url, type) {
    return new Promise((resolve, reject) => {
        if (type === 'image') {
            const img = new Image();

            const timeout = setTimeout(() => {
                reject(new Error('Image load timeout'));
            }, 10000); // 10 second timeout

            img.onload = () => {
                clearTimeout(timeout);
                resolve(img);
            };

            img.onerror = () => {
                clearTimeout(timeout);
                reject(new Error('Image load failed'));
            };

            img.src = url;
        } else if (type === 'video') {
            const video = document.createElement('video');

            const timeout = setTimeout(() => {
                reject(new Error('Video load timeout'));
            }, 15000); // 15 second timeout for videos

            video.addEventListener('loadeddata', () => {
                clearTimeout(timeout);
                resolve(video);
            });

            video.addEventListener('error', () => {
                clearTimeout(timeout);
                reject(new Error('Video load failed'));
            });

            video.src = url;
            video.preload = 'metadata'; // Only load metadata initially
        }
    });
}

// Intersection Observer for lazy loading
const imageObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            const fullUrl = img.dataset.fullUrl;

            if (fullUrl && !img.dataset.loading) {
                img.dataset.loading = 'true';

                preloadMedia(fullUrl, isNearViewport(img) ? 'medium' : 'low').then((fullImg) => {
                    img.style.opacity = '0';
                    setTimeout(() => {
                        img.src = fullImg.src;
                        img.style.opacity = '1';
                        img.removeAttribute('data-full-url');
                        img.removeAttribute('data-loading');
                        img.classList.remove("fcm_media_loading");
                    }, 200);
                }).catch(() => {
                    img.removeAttribute('data-loading');
                    img.classList.remove("fcm_media_loading");
                });
            }
        }
    });
}, {
    rootMargin: '200px' // Start loading 200px before entering viewport
});

function createOptimizedVideoElement(mediaData, thumbImg, playBtn, mediaWrapper) {
    let video = null;
    let videoLoaded = false;
    let hoverTimeout = null;
    let isHovering = false;

    const createVideo = () => {
        if (!video) {
            video = document.createElement('video');
            video.className = "fcm_media_video";
            video.loop = true;
            video.muted = true;
            video.playsInline = true;
            video.style.display = 'none';

            video.addEventListener('canplay', () => {
                videoLoaded = true;
                if (isNearViewport(video) && !video.controls) {
                    showVideo();
                }
            });

            video.addEventListener('loadstart', () => {
                console.log('Video loading started:', mediaData.url);
            });

            mediaWrapper.appendChild(video);
        }
        return video;
    };

    const showVideo = () => {
        if (video && videoLoaded) {
            thumbImg.style.display = 'none';
            playBtn.style.display = 'none';
            video.style.display = 'block';
            video.play().catch(() => { });
        }
    };

    const hideVideo = () => {
        if (video && !video.controls) {
            video.pause();
            video.style.display = 'none';
            thumbImg.style.display = 'block';
            playBtn.style.display = 'flex';
        }
    };

    // Hover enter - start loading after delay
    mediaWrapper.addEventListener('mouseenter', () => {
        isHovering = true;
        clearTimeout(hoverTimeout);

        hoverTimeout = setTimeout(() => {
            if (isHovering) { // Double-check still hovering
                const vid = createVideo();

                if (videoLoaded) {
                    // Video already loaded, show immediately
                    showVideo();
                }
                else if (!vid.src) {
                    // Queue the video load instead of hitting server immediately
                    preloadMedia(mediaData.url, 'high', 'video')
                        .then((videoEl) => {
                            vid.src = mediaData.url;
                        })
                        .catch(() => {
                            console.warn("Failed to queue video load:", mediaData.url);
                        });
                }
                // If video is loading, showVideo() will be called from 'canplay' event
            }
        }, 100); // Reduced delay for better responsiveness
    });

    // Hover leave - hide video and cancel loading if needed
    mediaWrapper.addEventListener('mouseleave', () => {
         isHovering = false;
         clearTimeout(hoverTimeout);
         //hideVideo();
    });

    // Click handler - permanent video with controls
    mediaWrapper.addEventListener('click', (e) => {
        if (!video || video.controls) return; // Already clicked or no video

        e.preventDefault();
        const vid = createVideo();

        // Set up for permanent display
        vid.muted = false;
        vid.controls = true;
        vid.style.display = 'block';
        thumbImg.style.display = 'none';
        playBtn.style.display = 'none';

        if (!vid.src) {
            vid.src = mediaData.url;
        }

        // Play when ready
        if (videoLoaded) {
            vid.play().catch(() => { });
        } else {
            vid.addEventListener('canplay', () => {
                vid.play().catch(() => { });
            }, { once: true });
        }
    });
}

// Replace the image creation part in createMasonryGrid function
function createOptimizedMediaElement(mediaData) {
    const mediaWrapper = document.createElement('div');
    mediaWrapper.className = "fcm_media_wrapper";

    if (mediaData.isVideo) {
        // Thumbnail image
        const thumbImg = document.createElement('img');
        thumbImg.src = mediaData.thumbnail;
        thumbImg.className = "fcm_media_thumb";
        thumbImg.loading = 'lazy';
        mediaWrapper.appendChild(thumbImg);

        // Play button overlay
        const playBtn = document.createElement('div');
        playBtn.className = "fcm_play_btn";
        playBtn.innerHTML = '&#9658;';
        mediaWrapper.appendChild(playBtn);

        // Use optimized video creation
        createOptimizedVideoElement(mediaData, thumbImg, playBtn, mediaWrapper);
    } else {
        const img = document.createElement('img');
        img.className = "fcm_media_img fcm_media_loading";
        img.src = mediaData.thumbnail;
        img.loading = 'lazy';
        img.dataset.fullUrl = mediaData.url;

        mediaWrapper.addEventListener('mouseenter', () => {
            updateQueuePriority(mediaData.url, 'high');
        });

        // Use Intersection Observer for lazy loading
        imageObserver.observe(img);
        mediaWrapper.appendChild(img);
    }

    // Filename tooltip
    const tooltip = document.createElement('div');
    tooltip.textContent = mediaData.originalName;
    tooltip.className = "fcm_tooltip";

    // Middle click handler
    mediaWrapper.addEventListener('mousedown', (event) => {
        if (event.button === 1) {
            window.open(mediaData.url, '_blank');
        }
    });

    mediaWrapper.appendChild(tooltip);
    return mediaWrapper;
}

// Add cleanup function for when grid is closed
function cleanupPreloading() {
    // Cancel pending loads
    loadQueue.forEach(item => {
        item.reject(new Error('Cleanup cancelled'));
    });
    loadQueue = [];
    activeLoads = 0;

    // this is handles with refresh
    // // Clear cache periodically to prevent memory leaks
    // if (preloadCache.size > 100) {
    //     preloadCache.clear();
    // }

    // Disconnect observer
    imageObserver.disconnect();
}

function initUI() {
    const button = document.createElement('span');
    button.title = "Masonry Grid";

    button.addEventListener('click', function(e) {
        e.preventDefault();
        openGrid();
    });

    const forchanX_header = document.getElementById("header-bar")
    if (forchanX_header) { // if 4chan X is detected

        const element = document.getElementById("shortcut-watcher");
        button.id = "shortcut-masonry";
        button.className = "shortcut brackets-wrap fcm_shortcut_4chanx";

        const img = document.createElement("img");
        img.src = userscript_icon_1;
        button.appendChild(img);


        img.addEventListener('mouseover', () => {
            if (!img.classList.contains('clicked')) {
                img.src = userscript_icon_2;
            }
        });


        img.addEventListener('mouseout', () => {
            if (!img.classList.contains('clicked')) {
                img.src = userscript_icon_1;
            }
        });

        img.addEventListener('mousedown', () => {
            img.classList.add('pressed');
            img.src = userscript_icon_3;
        });


        img.addEventListener('mouseup', () => {
            img.classList.remove('pressed');

            if (img.matches(':hover')) {
                img.src = userscript_icon_2;
            } else {
                img.src = userscript_icon_1;
            }
        });

        // also handle case where mouse is released outside the image
        document.addEventListener('mouseup', () => {
            if (img.classList.contains('pressed')) {
                img.classList.remove('pressed');
                if (img.matches(':hover')) {
                    img.src = 'icon2.png';
                } else {
                    img.src = 'icon1.png';
                }
            }
        });

        forchanX_header.appendChild(button);
        element.parentElement.insertBefore(button, element);
    }
    else {
        button.className = "fcm_button_regular";

        const img = document.createElement("img");
        img.src = userscript_icon_3;
        button.appendChild(img);

        const span = document.createElement("span");
        span.innerHTML = "Masonry Grid";
        button.appendChild(span);

        const containerDiv = document.createElement('div');
        containerDiv.id = "4chan_grid_cont";
        containerDiv.className = "fcm_container";

        containerDiv.appendChild(button);
        const threadElement = document.querySelector(".thread");
        threadElement.parentElement.insertBefore(containerDiv, threadElement);
    }

    findMediaLinks(button);
}

function findMediaLinks(button = null) {
    const mediaLinks = [];
    const files = document.querySelectorAll('div.file');

    files.forEach((fileDiv, index) => {
        const fileText = fileDiv.querySelector('.fileText');
        const fileThumb = fileDiv.querySelector('.fileThumb');
        const fileThumbImage = fileThumb.querySelector("img");
        let link = null;
        if (fileText) { link = fileText.querySelector('a'); }

        if (link && link.href) {
            const url = link.href.startsWith('//') ? 'https:' + link.href : link.href;

            const isImage = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i.test(url);
            const isVideo = /\.(mp4|webm|mkv|avi|mov)(\?|$)/i.test(url);

            if (isImage || isVideo) {
                const postId = url.split('/').pop().split('?')[0];
                let originalName = link.title.trim() || link.textContent.trim() || postId;

                // if 4chan-X is used fix the name fetching
                const fnfull = link.querySelector('.fnfull');
                if (fnfull) { originalName = fnfull.textContent.trim(); }

                // get file info text
                const fileInfo = fileDiv.querySelector(".file-info");
                let width = null;
                let height = null;
                if (fileInfo) {
                    const fileInfoClone = fileInfo.cloneNode(true);
                    fileInfoClone.querySelectorAll("a").forEach(a => a.remove());
                    const info = fileInfoClone.textContent.trim();
                    const size = info.split(", ")[1].replace(")", "");
                    const width_and_height = size.split("x");
                    width = width_and_height[0];
                    height = width_and_height[1];
                }

                const newElement = {
                    url: url,
                    originalName: originalName,
                    postId: postId,
                    index: index + 1,
                    isVideo: isVideo,
                    thumbnail: (fileThumbImage.src),
                    width: width,
                    height: height
                };
                mediaLinks.push(newElement);
            }
        }
    });

    if (button) {
        button.title = `Masonry Grid (${mediaLinks.length})`;
    }

    return mediaLinks;
}

function findMediaLinksFromImgAndVideoElements() {
    const mediaLinks = []
    const imgElements = document.querySelectorAll('img[src*="jpg"], img[src*="jpeg"], img[src*="png"], img[src*="gif"], img[src*="webp"], img[src*="bmp"]');
    const videoElements = document.querySelectorAll('video[src*="mp4"], video[src*="webm"], video[src*="mkv"], video[src*="avi"], video[src*="mov"]');
    const mediaElements = [...imgElements, ...videoElements];
    mediaElements.forEach((img_or_vid, index) => {
        const url = img_or_vid.src;
        const filename = url.split('/').pop().split('?')[0];
        const isVideo = /\.(mp4|webm|mkv|avi|mov)(\?|$)/i.test(url);

        mediaLinks.push({
            url: url,
            originalName: filename,
            postId: filename,
            index: index + 1,
            isVideo: isVideo
        });
    });
    return mediaLinks;
}

function updateMasonryGrid() {
    const gridContainer = document.getElementById('fcm_masonry');
    if (gridContainer) {
        gridContainer.style.columnCount = gridRows;
    }
}

function createTopBar() {
    const topbar = document.createElement("div");
    topbar.id = "fcm_topbar";

    const sliderContainer = document.createElement('div');
    sliderContainer.className = "fcm_slider_container";

    const slider = document.createElement('input');
    slider.type = 'range';
    slider.id = "setting_slider_cols";
    slider.min = GRID_ROWS_MIN;
    slider.max = GRID_ROWS_MAX;
    slider.step = "1";
    slider.value = gridRows;

    const valueDisplay = document.createElement('span');
    valueDisplay.textContent = gridRows;
    valueDisplay.className = "fcm_value_display";

    slider.addEventListener('input', (e) => {
        gridRows = parseInt(e.target.value);
        valueDisplay.textContent = gridRows;
        updateMasonryGrid();
    });

    function slider_left() { slider.value = Math.max(Number(slider.value) - Number(slider.step), slider.min); }
    function slider_right() { slider.value = Math.min(Number(slider.value) + Number(slider.step), slider.max); }

    slider.addEventListener('keydown', (event) => {
        switch (event.key) {
            case 'ArrowLeft': slider_left(); break;
            case 'ArrowRight': slider_right(); break;
        }
    });

    slider.focus();
    slider.select();

    sliderContainer.appendChild(slider);
    sliderContainer.appendChild(valueDisplay);
    topbar.appendChild(sliderContainer);

    // Close button
    const closeButton = document.createElement('button');
    closeButton.className = "fcm_close";
    closeButton.innerHTML = '✕';
    closeButton.addEventListener('click', closeGrid);

    topbar.appendChild(closeButton);
    return topbar;
}

function createMasonryGrid(mediaLinks) {
    const overlay = document.createElement('div');
    overlay.id = 'fcm_overlay';

    const topbar = createTopBar();

    let lastScroll = 0;
    overlay.addEventListener("scroll", () => {
        const currentScroll = overlay.scrollTop;

        if (currentScroll > lastScroll) { topbar.style.transform = "translateY(-100%)"; }
        else { topbar.style.transform = "translateY(0)"; }

        lastScroll = currentScroll;
    });

    overlay.appendChild(topbar);

    const container = document.createElement('div');
    container.className = "fcm_grid_container";

    const handleParentClick = (event) => {
        event.preventDefault();
        if (event.target === event.currentTarget) {
            closeGrid();
        }
    };

    overlay.addEventListener('click', handleParentClick);
    container.addEventListener('click', handleParentClick);
    topbar.addEventListener('click', handleParentClick);

    // Grid container
    const gridContainer = document.createElement('div');
    gridContainer.id = 'fcm_masonry';
    gridContainer.style.columnCount = gridRows;

    // Create image elements
    mediaLinks.forEach((mediaData, index) => {
        const mediaWrapper = createOptimizedMediaElement(mediaData);
        gridContainer.appendChild(mediaWrapper);
    });

    container.appendChild(gridContainer);
    overlay.appendChild(container);

    return overlay;
}

function openGrid() {
    if (isGridOpen) return;

    const mediaLinks = findMediaLinks();
    if (mediaLinks.length === 0) {
        alert('No media found on this page!');
        return;
    }

    gridOverlay = createMasonryGrid(mediaLinks);
    document.body.appendChild(gridOverlay);
    isGridOpen = true;

    // Prevent body scrolling
    document.body.style.overflow = 'hidden';

    // Close on escape key
    document.addEventListener('keydown', handleEscapeKey);
}

function closeGrid() {
    if (!isGridOpen || !gridOverlay) return;

    cleanupPreloading();

    document.body.removeChild(gridOverlay);
    gridOverlay = null;
    isGridOpen = false;
    document.body.style.overflow = 'auto';
    document.removeEventListener('keydown', handleEscapeKey);
}


function handleEscapeKey(e) {
    if (e.key === 'Escape') {
        closeGrid();
    }
}

async function init() {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
        return;
    }

    setTimeout(async () => {
        try {
            initUI();

        } catch (error) {
            console.error('Error initializing userscript:', error);
        }
    }, 500);
}

init();