bobsacramento / e621 Pool Viewer

// ==UserScript==
// @name         e621 Pool Viewer
// @namespace    http://tampermonkey.net/
// @version      0.4
// @description  Add carousel for viewing image pools
// @author       bobsacramento
// @license      MIT
// @match        https://e621.net/posts/*
// @icon         https://e621.net/favicon.ico
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @updateURL    https://openuserjs.org/meta/bobsacramento/e621_Pool_Viewer.meta.js
// @downloadURL  https://openuserjs.org/install/bobsacramento/e621_Pool_Viewer.user.js
// ==/UserScript==

/* jshint esversion: 8 */

const previewCount = 3;
const loadDelay = 500;

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// Function to check if the post is part of a pool
async function checkForPool(postId) {
    const postApiUrl = `https://e621.net/posts/${postId}.json`;

    return new Promise((resolve, reject) => {
        const poolRequest = {
            method: 'GET',
            url: postApiUrl,
            onload: response => resolve(handlePoolRequest(response)),
            onerror: error => reject(`Error fetching post details: ${error}`)
        };
        GM_xmlhttpRequest(poolRequest);
    });
}

async function handlePoolRequest(response) {
    const postDetails = JSON.parse(response.responseText);
    const pools = postDetails.post.pools;
    if (pools && pools.length > 0) {
        return await findLargestPool(pools);
    }
    else {
        throw new Error('This post is not part of any pool.');
    }
}

async function findLargestPool(poolIds)
{
    let poolSizes = poolIds.map(getPoolPostCount);
    poolSizes = await Promise.allSettled(poolSizes);
    poolSizes = poolSizes.map(result => result.status === 'fulfilled' ? result.value : 0);
    let [index, count] = indexOfMax(poolSizes);
    return [poolIds[index], count];
}

function indexOfMax(arr)
{
    if (arr.length === 0) {
        throw new Error("Empty array!");
    }

    let max = arr[0];
    let maxIndex = 0;
    for (let i = 1; i < arr.length; i++) {
        if (arr[i] > max) {
            maxIndex = i;
            max = arr[i];
        }
    }
    return [maxIndex, max];
}

async function getPoolPostCount(poolId)
{
    const poolDetailsApiUrl = `https://e621.net/pools/${poolId}.json`;

    return new Promise((resolve, reject) => {
        const poolDetailsRequest = {
            method: 'GET',
            url: poolDetailsApiUrl,
            onload: response => {
                const poolDetails = JSON.parse(response.responseText);
                const postCount = poolDetails.post_count;
                resolve(postCount);
            },
            onerror: error => reject(`Error fetching pool details: ${error}`)
        };
        GM_xmlhttpRequest(poolDetailsRequest);
    });
}

// Function to load images from the pool
async function loadPoolIds(poolId) {
    const poolApiUrl = `https://e621.net/pools/${poolId}.json`;

    const response = await fetch(poolApiUrl);
    const poolDetails = await response.json();

    return poolDetails.post_ids;
}


// Function to create a carousel/slider
async function createCarousel(currentPost, poolIds) {
    const poolSize = poolIds.length;

    var currentIndex = poolIds.indexOf(currentPost);
    if (currentIndex === -1) {
        throw new Error("Current post not in pool");
    }

    const imageDisplayArea = document.getElementById("image");
    if (imageDisplayArea.tagName !== 'image')
        throw new Error("Element is not an image. Aborting");

    const carouselContainer = document.createElement('div');
    carouselContainer.id = 'carouselContainer';
    carouselContainer.style.width = '100%';
    carouselContainer.style.overflow = 'hidden';

    // Create placeholders for all images in the pool
    poolIds.forEach(id => {
        const placeholder = createPlaceholder(id);
        carouselContainer.appendChild(placeholder);
    });

    // The current image is already loaded. Just move the src
    carouselContainer.childNodes[currentIndex].src = imageDisplayArea.src;
    carouselContainer.childNodes[currentIndex].setAttribute("data-loaded", "");

    const priorityList = buildPriorityList(currentIndex, poolSize);
    loadImages(priorityList, poolIds, carouselContainer);

    // Replace the current image display with the carousel
    imageDisplayArea.replaceWith(carouselContainer);

    document.addEventListener('keydown', event => {
        let prevIndex = currentIndex;
        if (event.key === 'ArrowLeft') {
            if (currentIndex > 0) {
                currentIndex = currentIndex - 1;
            }
        }
        if (event.key === 'ArrowRight') {
            if (currentIndex < poolSize - 1)
            currentIndex = currentIndex + 1;
        }
        updateCarouselImages(prevIndex, currentIndex);
    });

    function updateCarouselImages(prevIndex, currIndex) {
        const indexesToLoad = buildPriorityList(currIndex, poolSize);
        loadImages(indexesToLoad, poolIds, carouselContainer);

        const imgs = carouselContainer.childNodes;
        imgs[prevIndex].hidden = true;
        imgs[prevIndex].id = "";
        imgs[currIndex].hidden = false;
        imgs[currIndex].id = "image";
        imgs[currIndex].className = imgs[prevIndex].className;
    }
}

// Retrieves the image url from a post
async function getImgUrl(postId) {
    const postApiUrl = `https://e621.net/posts/${postId}.json`;
    const postResponse = await fetch(postApiUrl);
    const postData = await postResponse.json();
    return postData.post.file.url;
}

function optimizeImage(imageUrl) {
    const imgPreloader = document.createElement('link');
    imgPreloader.rel = "preload";
    imgPreloader.as = "image";
    imgPreloader.href = imageUrl;
    document.head.appendChild(imgPreloader);
}

function createPlaceholder(postId) {
    const imgPlaceholder = document.createElement('img');
    imgPlaceholder.dataset.postId = postId;
    imgPlaceholder.className = "fit-window";
    imgPlaceholder.hidden = true;
    return imgPlaceholder;
}

async function loadImages(order, poolIds, parent) {
    for (let index of order) {
        if (parent.childNodes[index].hasAttribute("data-loaded")) {
            continue;
        }
        const postId = poolIds[index];
        const imageUrl = await getImgUrl(postId);
        parent.childNodes[index].src = imageUrl;

        // Mark element as loaded
        parent.childNodes[index].setAttribute("data-loaded", "");

        // Optimization to force browser to load image even when hidden
        optimizeImage(imageUrl);

        // Load slowly not hit the rate limit
        await sleep(loadDelay);
    }
}

function buildPriorityList(current, maxLength) {
    // Calculate range of indices to load
    const start = Math.max(0, current - previewCount);
    const end = Math.min(maxLength, current + previewCount + 1);

    const priorityList = [current];
    // Next image
    if (current + 1 < end) {
        priorityList.push(current + 1);
    }
    // Previous image
    if (current - 1 >= start) {
        priorityList.push(current - 1);
    }
    // Rest of images
    for (let i = current + 2; i < end; i++) {
        priorityList.push(i);
    }
    // All previous images
    for (let i = current - 2; i >= start; i--) {
        priorityList.push(i);
    }
    return priorityList;
}

// Main function to start the script
async function main() {
    // Get the id of the current post
    const postId = parseInt(window.location.pathname.split('/')[2]);

    // Check if the post is part of a pool
    const poolId = await checkForPool(postId).catch(console.log);
    const poolIds = await loadPoolIds(poolId);
    createCarousel(postId, poolIds).catch(console.log);
}

// Run the main function when the page is loaded
main();