Rule34.xxx Viewer

Image viewer with keyboard navigation for rule34.xxx content.

// ==UserScript==
// @name        Rule34.xxx Viewer
// @description Image viewer with keyboard navigation for rule34.xxx content.
// @namespace   https://sleazyfork.org/en/scripts/463046-rule34-xxx-viewer
// @author      bliblux
// @version     1.0.0
// @license     MIT
// @match *://rule34.xxx/index.php?page=post&s=list*
// @grant GM_info
// @grant GM_xmlhttpRequest
// ==/UserScript==

(async function () {
    console.log(`start script <${GM.info.script.name}> version <${GM.info.script.version}>`);
    let unloadedImageLinks = [];
    let imageCount = 0;
    let imageData = [];
    let imageTagLists = [];
    let imageLoadInterval = null;
    let viewerImageIndex = 1;
    let imageDataReady = false;
    initUnloadedImageLinks();
    initButtons();
    initViewer();
    initKeyboardEventHandler();
    initButtonCss();
    initViewerCss();

    //----------------------------------------------------------------------------------------------------------------//
    //BUTTONS---------------------------------------------------------------------------------------------------------//
    //----------------------------------------------------------------------------------------------------------------//
    function initButtons() {
        // create a div above with buttons above the image board
        const parentNode = document.querySelector('div.content');
        const ButtonContainerDiv = document.createElement('div');
        ButtonContainerDiv.id = 'button-container';
        const buttons = Array.of(
            createLoadImagesButton(),
            createViewerStatusDisplay(),
            createViewerButton()
        );
        if (parentNode) {
            buttons.forEach(button => {
                parentNode
                    .insertBefore(ButtonContainerDiv, parentNode.firstChild)
                    .appendChild(button);
            })
        }
        createShortcutDisplay()
    }

    function createViewerButton() {
        const viewerButton = document.createElement('button');
        viewerButton.id = 'viewer-button';
        viewerButton.innerText = 'Open Viewer';
        viewerButton.classList.add('button-disabled');
        viewerButton.addEventListener('click', function () {
            if (viewerButton.classList.contains('button-active')) {
                toggleViewer();
            }
        });
        return viewerButton;
    }

    function createLoadImagesButton() {
        const loaderButton = document.createElement('button');
        loaderButton.id = 'loader-button';
        loaderButton.innerText = 'Load Images';
        loaderButton.title = 'Hold down to load images!';
        loaderButton.addEventListener('click', tryLoadingAllImages);
        return loaderButton;
    }

    function createViewerStatusDisplay() {
        const statusDisplaySpan = document.createElement('span');
        statusDisplaySpan.id = 'viewer-status-display';
        statusDisplaySpan.innerText = 'Not Ready';
        return statusDisplaySpan;
    }

    function createShortcutDisplay() {
        const parentNode = document.getElementById('button-container');
        const shortCutsHtml = `
            <div id="shortcut-display">
                <p><span>shift+Q: </span><span>load images</span></p>
                <p><span>shift+space: </span><span>toggle viewer</span></p>
                <p><span>arrow keys: </span><span>viewer navigation</span></p>
            </div>`
        parentNode.lastElementChild.insertAdjacentHTML('afterend', shortCutsHtml);
    }

    function setViewerButtonToReady() {
        const viewerButton = document.getElementById('viewer-button');
        viewerButton.classList.replace('button-disabled', 'button-active');
    }

    //----------------------------------------------------------------------------------------------------------------//
    //IMAGE-DATA------------------------------------------------------------------------------------------------------//
    //----------------------------------------------------------------------------------------------------------------//
    function initUnloadedImageLinks() {
        // get image source link from the image preview parent anchor element
        unloadedImageLinks = Array.from(document.querySelectorAll('img.preview'), (link, index) => ({
            url: link.parentNode.href,
            index: index + 1
        }));
        imageCount = unloadedImageLinks.length;
    }

    function loadAllImages() {
        if (imageData.length === imageCount) {
            clearInterval(imageLoadInterval);
            setViewerStatusToReady();
            setViewerButtonToReady();
        } else {
            fetchUnloadedImageData();
        }
    }

    function getImageXhrData(link) {
        GM.xmlHttpRequest({
            url: link.url,
            method: 'GET',
            context: {
                index: link.index,
            },
            onload: createImageDataAndTagListFromXhrData
        })
    }

    function fetchUnloadedImageData() {
        // remove image links if their image data has already been loaded
        const loadedImageIndexes = new Set(imageData.map(imageData => imageData.index));
        unloadedImageLinks = unloadedImageLinks.filter(link => !loadedImageIndexes.has(link.index));

        unloadedImageLinks.forEach(link => {
            getImageXhrData(link);
        });
    }

    function createImageDataAndTagListFromXhrData(xhr) {
        if (xhr.status !== 200) {
            return;
        }
        const domParser = new DOMParser();
        const document = domParser.parseFromString(xhr.response, 'text/html');
        const imageElement = document.getElementById('image');
        const videoElement = document.getElementById('gelcomVideoPlayer');

        // all relevant tag list items for the viewer have a class except the .current-page list item
        const tagListItemNodes = document.querySelectorAll('li[class]:not(.current-page)');
        const tagListElements = Array.from(tagListItemNodes).map(node => {
            const element = document.createElement(node.tagName.toLowerCase())
            element.className = node.className;
            for (let child of node.children) {
                const childElement = document.createElement(child.tagName.toLowerCase());
                if (child.tagName.toLowerCase() === 'a') {
                    childElement.setAttribute('href', child.getAttribute('href'));
                }
                childElement.innerHTML = child.innerHTML;
                childElement.className = child.className;
                element.appendChild(childElement);
            }
            return element;
        });

        const imageIndex = xhr.context.index;
        if (!imageData.some(object => object.index === imageIndex)) {
            if (videoElement) {
                imageData.push({
                    image: videoElement,
                    index: imageIndex
                });
            } else {
                imageData.push({
                    image: imageElement,
                    index: imageIndex
                });
            }
            imageTagLists.push({
                tagList: tagListElements,
                index: imageIndex
            })
        }
    }

    function setViewerStatusToReady() {
        const statusDisplay = document.getElementById('viewer-status-display');
        statusDisplay.innerText = 'Ready';
        statusDisplay.className = 'viewer-status-ready';
        imageDataReady = true;
    }

    function setViewerStatusToLoading() {
        const statusDisplay = document.getElementById('viewer-status-display');
        statusDisplay.innerText = 'Loading';
        statusDisplay.className = 'viewer-status-loading';
    }

    //----------------------------------------------------------------------------------------------------------------//
    //KEYBOARD-EVENTS-------------------------------------------------------------------------------------------------//
    //----------------------------------------------------------------------------------------------------------------//
    function initKeyboardEventHandler() {
        window.addEventListener('keydown', function (event) {
            if (event.shiftKey && event.code === 'KeyQ') {
                tryLoadingAllImages();
            }
            if (event.shiftKey && event.code === 'Space') {
                toggleViewer();
            }
            if (event.code === 'ArrowUp') {
                increaseVideoVolume();
            }
            if (event.code === 'ArrowDown') {
                decreaseVideoVolume();
            }
            if (event.code === 'ArrowRight') {
                displayNextImage();
            }
            if (event.code === 'ArrowLeft') {
                displayPreviousImage();
            }
        });
    }

    function tryLoadingAllImages() {
        // only start a new interval if no other interval is running
        if (!imageLoadInterval || !window.setInterval.hasOwnProperty(imageLoadInterval)) {
            imageLoadInterval = setInterval(loadAllImages, 100);
            setTimeout(cancelLoadingImages, 10000);
            setViewerStatusToLoading();
        }
    }

    function cancelLoadingImages() {
        if (window.setInterval.hasOwnProperty(imageLoadInterval)) {
            clearInterval(imageLoadInterval);
            window.alert('Image loading was canceled because it was taking unusually long!');
        }
    }

    //----------------------------------------------------------------------------------------------------------------//
    //VIEWER----------------------------------------------------------------------------------------------------------//
    //----------------------------------------------------------------------------------------------------------------//
    function initViewer() {
        // create and add elements that will act as the image viewer when active
        const containerDiv = document.createElement('div');
        containerDiv.id = 'viewer-container';
        containerDiv.classList.add('viewer-container');
        containerDiv.classList.add('viewer-inactive');
        const viewerHtml = `
            <div id="viewer-tag-list" class="viewer-tag-list"></div>
            <div id="viewer-image-display" class="viewer-image-display">
            <img id="viewer-image" src="" alt=""></div>
            <div id="viewer-footer-navigation" class="viewer-footer-navigation">
            <button id="viewer-navigation-button-previous" class="viewer-navigation-button">Previous</button>
            <button id="viewer-navigation-button-source" class="viewer-navigation-button">Source</button>
            <button id="viewer-navigation-button-index" class="viewer-navigation-button">--</button>
            <button id="viewer-navigation-button-close" class="viewer-navigation-button">Close</button>
            <button id="viewer-navigation-button-next" class="viewer-navigation-button">Next</button></div>
        `;
        containerDiv.insertAdjacentHTML('beforeend', viewerHtml);
        document.body.appendChild(containerDiv);
        document.getElementById('viewer-navigation-button-close').addEventListener('click', toggleViewer);
        document.getElementById('viewer-navigation-button-next').addEventListener('click', displayNextImage);
        document.getElementById('viewer-navigation-button-previous').addEventListener('click', displayPreviousImage);
        document.getElementById('viewer-navigation-button-source').addEventListener('click', navigateToImageSource);
    }

    function toggleViewer() {
        const viewerDiv = document.querySelector('div.viewer-container');
        if (!imageDataReady) {
            return;
        }
        if (viewerDiv.classList.contains('viewer-inactive')) {
            viewerDiv.classList.replace('viewer-inactive', 'viewer-active');
            displayImage();
        } else {
            viewerDiv.classList.replace('viewer-active', 'viewer-inactive');
            pauseVideo();
        }
    }

    function displayImage() {
        // get image of current index and update the viewer image accordingly
        const viewerImage = document.getElementById('viewer-image');
        const currentImage = imageData.find(image => image.index === viewerImageIndex).image;

        if (viewerImage.tagName !== currentImage.tagName) {
            const newImage = document.createElement(currentImage.tagName);
            if (currentImage.tagName === 'VIDEO') {
                newImage.id = 'viewer-image';
                newImage.src = currentImage.firstElementChild.src;
                newImage.controls = true;
                newImage.loop = true
            } else if (currentImage.tagName === 'IMG') {
                newImage.id = 'viewer-image';
                newImage.src = currentImage.src;
                newImage.alt = currentImage.alt;
            }
            viewerImage.replaceWith(newImage);
        } else if (currentImage.tagName === 'VIDEO') {
            viewerImage.src = currentImage.firstElementChild.src;
        } else if (currentImage.tagName === 'IMG') {
            viewerImage.src = currentImage.src;
            viewerImage.alt = currentImage.alt;
        }
        playVideo();

        // fill the viewer tag list with the current items
        const viewerTagList = document.getElementById('viewer-tag-list');
        viewerTagList.innerHTML = '<h4 style="color:#a0a0a0;">Tags</h4>';
        const imageTagList = imageTagLists.find(tagList => tagList.index === viewerImageIndex).tagList;
        imageTagList.forEach(tagList => viewerTagList.appendChild(tagList));

        // set index indicator of the viewer
        document.getElementById('viewer-navigation-button-index').innerText = viewerImageIndex;
    }

    function playVideo() {
        const image = document.getElementById('viewer-image');
        if (image.tagName === 'VIDEO') {
            image.volume = 0.05;
            image.play();
        }
    }

    function pauseVideo() {
        const image = document.getElementById('viewer-image');
        if (image.tagName === 'VIDEO') {
            image.pause();
        }
    }

    function increaseVideoVolume() {
        const image = document.getElementById('viewer-image');
        if (image.tagName === 'VIDEO') {
            image.volume += 0.05;
        }
    }

    function decreaseVideoVolume() {
        const image = document.getElementById('viewer-image');
        if (image.tagName === 'VIDEO') {
            image.volume -= 0.05;
        }
    }

    function displayNextImage() {
        viewerImageIndex = viewerImageIndex < imageCount ? viewerImageIndex + 1 : 1;
        displayImage();
    }

    function displayPreviousImage() {
        viewerImageIndex = viewerImageIndex > 1 ? viewerImageIndex - 1 : imageCount;
        displayImage();
    }

    function navigateToImageSource() {
        window.open(document.getElementById('viewer-image').src, '_blank');
    }

    //----------------------------------------------------------------------------------------------------------------//
    //CSS-CLASSES-----------------------------------------------------------------------------------------------------//
    //----------------------------------------------------------------------------------------------------------------//
    function addCssToHead(css, styleId) {
        const style = document.createElement('style');
        if (styleId) {
            style.setAttribute('id', styleId);
        }
        style.appendChild(document.createTextNode(css));
        return document.head.appendChild(style);
    }

    function initButtonCss() {
        addCssToHead(`
            #button-container {
                display: flex;
                justify-content: center;
                align-items: center;
            }
            #button-container button {
                width: 180px;
				height: 50px;
				text-align: center;
				background-color: #84AE83;
				color: #FFFFFF;
				font-weight: bold;
				border: none;
				margin: 3px 10px;
				cursor: pointer;
            }
            #shortcut-display {
                display: flex;
                justify-content: center;
                align-items: center;
                display: inline-block;
                height: 50px;
                width: 180px;
            }
            #shortcut-display p {
                display: flex;
                justify-content: space-between;
                align-items: center;
                text-align: start;
                font-size: 10px;
                height: 1em;
                color: #666;
            }
            #viewer-container button:hover, #button-container button:hover {
                background: #A4CEA3;
                color: #FFFFFF;
            }
            #button-container button.button-disabled {
                background: #C0C0C0;
                color: #808080;
                cursor: default;
            }
            #button-container button.button-active {
                background: #84AE83;
                color: #FFFFFF;
                cursor: pointer;
            }
            #viewer-status-display {
                display: inline-block;
                width: 500px;
                height: 50px;
                line-height: 50px;
                text-align: center;
                background-color: red;
                color: white;
                font-weight: bold;
                border: none;
                margin: 3px 10px;
                user-select: none;
                pointer-events: none;
            }
            #viewer-status-display.viewer-status-loading {
                color: black;
                background-color: white;
                transition: background-color 0s linear;
                animation: turnYellow 2s linear forwards;
            }
            #viewer-status-display.viewer-status-ready {
                background-color: white;
                transition: background-color 0s linear;
                animation: turnGreen 2s linear forwards;
            }
            @keyframes turnYellow {
                0% {
                    background-color: white;
                }
                50% {
                    background-color: yellow;
                }
                100% {
                    background-color: yellow;
                }
            }
            @keyframes turnGreen {
                0% {
                    background-color: white;
                }
                50% {
                    background-color: green;
                }
                100% {
                    background-color: green;
                }
            }
}
        `, 'button-div-css');
    }

    function initViewerCss() {
        addCssToHead(`
			.viewer-container {
				position: fixed;
				top: 0;
				right: 0;
				bottom: 0;
				left: 0;
				z-index: 100100;
				background-color: #000000;
			}
			.viewer-inactive {
			    display: none;
			}
			.viewer-active {
			    display: block;
			}
			button.viewer-navigation-button {
				cursor: pointer;
			}
			.viewer-tag-list {
				position: absolute;
				width: 200px;
				min-width: 50px;
				top: 0;
				left: 0;
				overflow-y: auto;
				height: 100%;
				background-color: #303030;
				opacity: 0.7;
				transition: all 0.5s;
			}
			.viewer-tag-list:hover {
			    opacity: 1;
			}
			.viewer-tag-list a:hover {
			    color: #FFFFFF;
			}
			.viewer-tag-list li {
				list-style-type: none;
				line-height: 1.8em;
				display: block;
				padding-left: 4px;
			}
			.viewer-tag-list * {
				background-color: #303030;
			}
			.viewer-tag-list li > * {
			    margin-right: 0.4em;
			}
			.viewer-tag-list li > span {
			    color: #a0a0a0;
			}
			.viewer-tag-list li.tag-type-general a{
				color: #337ab7;
			}
			.viewer-image-display {
                position: absolute;
                top: 0;
                left: 200px;
                right: 0;
                bottom: 21px;
                display: flex;
                justify-content: center;
                align-items: center;
            }
            .viewer-image-display * {
                width: 100%;
                height: 100%;
                object-fit: contain;
            }
			.viewer-footer-navigation {
                position: absolute;
                bottom: 0px;
                left: 200px;
                width: calc(100% - 200px);
                height: 21px;
                display: flex;
                justify-content: center;
                align-items: center;
                background-color: #000000;
                opacity: 0.2;
            }
			.viewer-footer-navigation:hover {
				opacity: 1;
			}
			.viewer-footer-navigation button {
				color: #FFFFFF;
				background-color: #303030;
				cursor: initial;
				margin: 1px 1px 3px 1px;
				padding: 1px 5px;
				border: 0;
				font-weight: bold;
			}
		`, 'viewer-css');
    }
})();