Galleria

Sankaku Complex (Idol) Gallery Overlay

// ==UserScript==
// @name         Galleria
// @version      0.3.1
// @description  Sankaku Complex (Idol) Gallery Overlay
// @homepage     https://github.com/agony-central/Galleria
// @supportURL   https://github.com/agony-central/Galleria/issues
// @author       agony_central
// @include      /^https?://idol.sankakucomplex.com/?(\?[^/]+)?$/
// @require      https://code.jquery.com/jquery-3.6.2.slim.js
// @require      https://unpkg.com/react@18/umd/react.production.min.js
// @require      https://unpkg.com/react-dom@18/umd/react-dom.production.min.js
// @icon         https://idol.sankakucomplex.com/favicon.png
// @grant        unsafeWindow
// @namespace https://greasyfork.org/users/1000369
// ==/UserScript==

(async () => {
    const LOG_PREFIX = '[Galleria]';
    const LOG_LEVEL = 1;
    const print = (level, ...args) => {
        if (level <= LOG_LEVEL) console.info(LOG_PREFIX, ...args);
    };

    const RESOURCE_PREFIX = '__GALLERIA_';
    const rn = (name) => RESOURCE_PREFIX + name;

    const FETCH_MEDIA_RATE_LIMIT = 5000;
    const CHECK_NEW_POST_INTERVAL = 1800;
    const CHECK_NEW_PAGE_INTERVAL = 5000;
    const LOOKAHEAD_POST_COUNT = 32;

    const COMMON_CLASSNAME_POST = 'thumb';

    const { $ } = window;
    const { React } = window;
    const { ReactDOM } = window;
    const e = React.createElement;

    /**
     *
     * The page we are on stores all posts in "#content > .content"
     * however, the page has infinite scroll pagination.
     *
     * There are three details to note about the pagination:
     *
     * First: the post storage ("#content > .content") stores the
     * first page as a direct child div, with no ID.
     *
     * Second: the rest of the pages are stored as siblings of
     * the first page, with IDs of the form "content-page-n" where
     * `n` is the page number (NOT zero-indexed, starts at 2 since the
     * first page has no ID).
     *
     * Third: the first page's first element is a grouped, promotional
     * post with the ID "popular-preview", which we will ignore.
     *
     * =================================================================
     *
     * Galleria is concerned with two things:
     *
     *  [UI] overlaying a gallery on the page, that can be:
     *    a. toggled on and off
     *    b. navigated easily
     *
     *  [LOADER] keeping an actively loaded list of posts, by:
     *    a. scrolling to the bottom of the page when appropriate
     *      1. when the number of remaining posts ahead of the
     *         currently viewed post is less than LOOKAHEAD_POST_COUNT
     *    b. loading the media of posts as they are encountered
     *      1. creating an iframe to the post's page
     *      2. extracting the media from the iframe
     *      3. storing the media in the post's data
     *
     * =================================================================
     */

    /**
     * globalStore
     *
     * An object that manages the current state of the UI and LOADER
     * components of Galleria.
     */
    const globalStore = {
        ui: {
            // whether the React app is currently mounted
            mounted: false,
            // whether the gallery is currently visible
            visible: false,
            // index of the currently viewed post
            currentPostIndex: 0,
        },
        loader: {
            // last encountered page number
            lastPage: 0,
            // all posts encountered so far
            posts: [],
            // last time a post's media was loaded
            lastFetch: 0,
        },
    };

    const ref_elem_Content = $('#content .content');
    // const ref_elem_GalleryMediaStore = $(
    //     `<div id="${rn('media-store')}"></div>`
    // );
    const ref_elem_GalleryRoot = $(`<div id="${rn('root')}"></div>`).appendTo(
        'body'
    );

    /**
     * Current post React component
     */
    class CurrentPost extends React.Component {
        constructor(props) {
            super(props);
            this.iframeRef = React.createRef();

            this.pruneIframe = this.pruneIframe.bind(this);
        }

        render() {
            return e('iframe', {
                ref: this.iframeRef,
                src: this.props.data.link,
                style: {
                    boxSizing: 'border-box',
                    width: '100%',
                    height: '100%',
                    margin: 0,
                    padding: 0,
                    border: 0,
                },
                onLoad: this.pruneIframe,
            });
        }

        pruneIframe() {
            // $(this.iframeRef.current.contentDocument)
            //     .contents()
            //     .find('*')
            //     .not('#image')
            //     .remove();
        }
    }

    /**
     * Post preview (thumbnail) React component
     */
    class PostPreview extends React.Component {
        state = {
            hover: false,
        };

        constructor(props) {
            super(props);
        }

        render() {
            return e(
                'div',
                {
                    key: this.props.id,
                    index: this.props.index,
                    style: {
                        position: 'relative',
                        boxSizing: 'border-box',
                        width: '100%',
                        height: '200px',
                        margin: 0,
                        padding: 0,
                        backgroundColor: this.props.active ? 'white' : 'black',
                    },
                    onClick: () => this.props.handleClick(this.props.index),
                    onMouseEnter: () => this.setState({ hover: true }),
                    onMouseLeave: () => this.setState({ hover: false }),
                },
                [
                    e('img', {
                        src: this.props.src,
                        style: {
                            width: '100%',
                            height: '100%',
                            margin: 0,
                            padding: 0,
                            objectFit: this.state.hover ? 'contain' : 'cover',
                        },
                    }),
                    this.props.active
                        ? e('div', {
                            style: {
                                position: 'absolute',
                                top: 0,
                                left: 0,
                                width: '100%',
                                height: '100%',
                                backgroundColor: 'rgba(0, 0, 0, 0.7)',
                            },
                        })
                        : undefined,
                ]
            );
        }
    }

    /**
     * Sidebar React component of loaded posts
     */
    class PostList extends React.Component {
        constructor(props) {
            super(props);
            this.ref_elem_Sidebar = React.createRef();
        }

        render() {
            return e(
                'div',
                {
                    ref: this.ref_elem_Sidebar,
                    style: {
                        display: 'inline-block',
                        boxSizing: 'border-box',
                        overflow: 'hidden scroll',
                        width: '100%',
                        height: '100%',
                        margin: 0,
                        padding: 0,
                    },
                },
                [
                    this.props.data.map((post, index) =>
                        e(PostPreview, {
                            id: post.id,
                            index,
                            src: post.thumbnail,
                            active: index == this.props.currentPostIndex,
                            handleClick: this.props.handleClick,
                        })
                    ),
                ]
            );
        }
    }

    /**
     * Root React component
     */
    class Galleria extends React.Component {
        constructor(props) {
            super(props);
            this.state = { ...props.globalStore };

            this.syncGlobalStore = this.syncGlobalStore.bind(this);
            this.toggleGallery = this.toggleGallery.bind(this);
            this.updateCurrentPostIndex =
                this.updateCurrentPostIndex.bind(this);
            this.viewNextPost = this.viewNextPost.bind(this);
            this.viewPreviousPost = this.viewPreviousPost.bind(this);
            this.handleKeyDown = this.handleKeyDown.bind(this);
        }

        componentWillMount() {
            this.props.ensureMinimumPosts();
            document.addEventListener('keydown', this.handleKeyDown);
        }

        componentDidMount() {
            globalStore.ui.mounted = true;
            this.syncGlobalStore();
        }

        componentWillUnmount() {
            globalStore.ui.mounted = false;
            document.removeEventListener('keydown', this.handleKeyDown);
        }

        syncGlobalStore() {
            this.setState({ ...this.props.globalStore });
        }

        toggleGallery(force = null) {
            if (force !== null) {
                globalStore.ui.visible = force;
            } else {
                globalStore.ui.visible = !globalStore.ui.visible;
            }

            this.syncGlobalStore();
        }

        updateCurrentPostIndex(newPostIndex) {
            globalStore.ui.currentPostIndex = newPostIndex;
            this.syncGlobalStore();

            this.props.ensureMinimumPosts();
        }

        viewNextPost() {
            const nextPostIndex = this.state.ui.currentPostIndex + 1;
            if (nextPostIndex >= this.state.loader.posts.length) return;

            this.updateCurrentPostIndex(nextPostIndex);
        }

        viewPreviousPost() {
            const previousPostIndex = this.state.ui.currentPostIndex - 1;
            if (previousPostIndex < 0) return;

            this.updateCurrentPostIndex(previousPostIndex);
        }

        handleKeyDown(e) {
            switch (e.key) {
            case 'g':
            case 'f':
                this.toggleGallery();
                break;
            case 'Escape':
                this.toggleGallery(false);
                break;
            case 'd':
            case 's':
            case 'ArrowDown':
            case 'ArrowRight':
                this.viewNextPost();
                break;
            case 'a':
            case 'w':
            case 'ArrowUp':
            case 'ArrowLeft':
                this.viewPreviousPost();
                break;
            }
        }

        render() {
            window.document.body.style.overflowY = this.state.ui.visible
                ? 'hidden'
                : 'scroll';

            return e('section', {}, [
                this.state.ui.visible
                    ? e(
                        'div',
                        {
                            style: {
                                position: 'fixed',
                                top: 0,
                                left: 0,
                                width: '100vw',
                                height: 'calc(100vh - 50px)',
                                zIndex: 10001,
                                display: 'grid',
                                gridTemplateColumns: '1fr 4fr',
                                backgroundColor: 'black',
                                color: 'white',
                            },
                        },
                        [
                            e(PostList, {
                                data: this.state.loader.posts,
                                currentPostIndex:
                                      this.state.ui.currentPostIndex,
                                handleClick: this.updateCurrentPostIndex,
                            }),
                            e(CurrentPost, {
                                index: this.state.ui.currentPostIndex,
                                data: this.state.loader.posts[
                                    this.state.ui.currentPostIndex
                                ],
                                loadPost: () =>
                                    this.props.loadPostMediaByIndex(
                                        this.state.ui.currentPostIndex
                                    ),
                            }),
                        ]
                    )
                    : null,
                e(
                    'button',
                    {
                        onClick: this.toggleGallery,
                        style: {
                            boxSizing: 'content-box',
                            width: '100%',
                            height: '50px',
                            margin: 0,
                            position: 'fixed',
                            left: 0,
                            bottom: 0,
                            zIndex: 10002,
                            border: 0,
                            backgroundColor: 'black',
                            color: 'white',
                        },
                    },
                    'Toggle Gallery'
                ),
            ]);
        }
    }

    async function loadNewPosts(newPosts) {
        let postsLoaded = 0;
        for (let i = 0; i < newPosts.length; i++) {
            const post = newPosts[i];

            /**
             * Skip elements that don't have the class name
             * `COMMON_CLASSNAME_POST`.
             */
            if (!post.classList.contains(COMMON_CLASSNAME_POST)) continue;

            /**
             * Posts, as they are loaded, start out with an inline style
             * of "display: none;" — we iterate over each post, waiting
             * for the display to no longer be "none", and then we add
             * its ID, link, and thumbnail as an object to
             * `globalStore.loader.posts`.
             *
             * This is technically unnecessary, since the data we need
             * is already available in the DOM, but it is a good
             * idea to wait for the post to be fully loaded before
             * adding it to the globalStore.
             */
            while (post.style.display === 'none') {
                await new Promise((r) =>
                    setTimeout(r, CHECK_NEW_POST_INTERVAL)
                );
            }

            globalStore.loader.posts.push({
                id: post.id.substring(1),
                link: post.children[0].href,
                thumbnail: post.children[0].children[0].src,
            });

            if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore();

            postsLoaded++;
        }

        print(2, `Loaded ${postsLoaded} new posts.`);
    }

    async function parseNewPages() {
        const lastPageInDOM = ref_elem_Content.find('.content-page').last();
        const lastPageNumber = lastPageInDOM.length
            ? parseInt(lastPageInDOM.attr('id').split('-')[2])
            : 1;

        print(2, 'Last page in DOM:', lastPageNumber);
        if (lastPageNumber == 1) return;

        /**
         * Iterate over any new pages, loading their posts.
         */
        for (
            let idx = globalStore.loader.lastPage + 1;
            idx <= lastPageNumber;
            idx++
        ) {
            const pageID = `#content-page-${idx}`;
            print(2, `Parsing page "${pageID}" [${idx}/${lastPageNumber}]...`);

            const newPosts = $(pageID).children();
            await loadNewPosts(newPosts);

            globalStore.loader.lastPage = idx;
        }
    }

    /**
     * This function is called repeatedly to check if new posts
     * need to be loaded, and if so, updates the globalStore.
     */
    async function ensureMinimumPosts() {
        print(2, 'Ensuring minimum posts...');

        /**
         * If the `globalStore.loader.lastPage` is 0, then we have not
         * yet initialized — so we load the first page while ignoring
         * the first post (the promotional post).
         */
        if (globalStore.loader.lastPage === 0) {
            print(2, 'Loading first page...');

            const newPosts = ref_elem_Content
                .children()
                .first()
                .children()
                .slice(1);
            await loadNewPosts(newPosts);

            globalStore.loader.lastPage = 1;
        }

        let prevPostCount = globalStore.loader.posts.length;
        while (
            prevPostCount - globalStore.ui.currentPostIndex <
            LOOKAHEAD_POST_COUNT
        ) {
            print(2, 'Scrolling to next "page" for more posts...');
            unsafeWindow.scrollTo(0, unsafeWindow.document.body.scrollHeight);

            // wait a split second to let the page load
            await new Promise((r) => setTimeout(r, 450));

            await parseNewPages();
            if (prevPostCount === globalStore.loader.posts.length) {
                // should have waited longer
                print(
                    1,
                    'Haven\'t found new page yet, waiting before trying again...'
                );
                await new Promise((r) =>
                    setTimeout(r, CHECK_NEW_PAGE_INTERVAL)
                );
            } else {
                prevPostCount = globalStore.loader.posts.length;
            }
        }
    }

    async function loadPostMediaByIndex(index) {
        if (
            Date.now() - globalStore.loader.lastMediaLoad <
            FETCH_MEDIA_RATE_LIMIT
        ) {
            print(2, 'Media load rate limit exceeded');
            const timeRemaining =
                FETCH_MEDIA_RATE_LIMIT -
                (Date.now() - globalStore.loader.lastMediaLoad);
            await new Promise((r) => setTimeout(r, timeRemaining));
        }

        const post = globalStore.loader.posts[index];
        if (!post) {
            print(1, 'No post found for index', index);
            return;
        }

        if (post.fullMedia) {
            print(2, 'Media already loaded for post', index);
            return;
        }

        print(2, 'Loading media for post', index);
        // use jquery to create iframe to post.link
        // then use jquery to find media in iframe
        // then use jquery to remove iframe

        const iframe = $(`<iframe src="${post.link}"></iframe>`);
        iframe.css('display', 'none');
        $('body').append(iframe);

        let iframeLoading = true;
        iframe.on('load', () => {
            iframeLoading = false;
        });

        print(2, 'Waiting for iframe to load...');
        await new Promise((r) => setTimeout(r, 2000));
        while (iframeLoading) {
            print(2, 'Still waiting for iframe to load...');
            await new Promise((r) => setTimeout(r, 2000));
        }

        const media = iframe.contents().find('#image')[0];
        print(2, 'iframe loaded with media:', media);
        post.fullMedia = iframe;

        globalStore.loader.lastMediaLoad = Date.now();
        if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore();
    }

    /**
     * Initialize React application
     */
    const __GALLERIA = ReactDOM.render(
        e(Galleria, {
            globalStore,
            ensureMinimumPosts,
            loadPostMediaByIndex,
        }),
        ref_elem_GalleryRoot[0]
    );
})();