MassiveFap

A complete ImageFap.com gallery conversion script featuring customization options and multiple viewing modes. Original author is Ryan Thaut, but it looks like abandoned.

Verze ze dne 24. 11. 2020. Zobrazit nejnovější verzi.

// ==UserScript==
// @name        MassiveFap
// @author      Ryan Thaut + patches from Elandoris
// @description A complete ImageFap.com gallery conversion script featuring customization options and multiple viewing modes. Original author is Ryan Thaut, but it looks like abandoned.
// @include     https://*imagefap.com/*
// @version     1.7.5
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @grant       GM_addStyle
// @namespace https://greasyfork.org/users/705794
// ==/UserScript==


/*  ===== Integration =====
 This is where the initial/basic eventListeners are added.
 */
window.addEventListener('load', init, false);
document.addEventListener('DOMContentLoaded', init, false);


/*  ===== Global Variables =====
 Changing these is a terrible idea; they are NOT for configuration
 */
var fullscreen = false;
var loaded = false;
var images = [];
var imagesMap = new Map();
var head, body, title, addFavLink;
var author = {name: '', url: ''};
var activeImage = (parseInt(getHashParam('image'), 10) - 1) || 0;
var totalImages = 0;
var hotkeys = [
    {
        action: displayHelp,
        codes: [191],
        keys: '?',
        label: 'Display Help',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: true}
    },
    {
        action: displayAbout,
        codes: [65],
        keys: 'a',
        label: 'Display About',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: toggleFullScreen,
        codes: [70],
        keys: 'f',
        label: 'Toggle Full Screen',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: switchGalleryMode,
        codes: [71],
        keys: 'g',
        label: 'Switch Gallery Mode',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: changeSettings,
        codes: [83],
        keys: 's',
        label: 'Change Settings',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: toggleThumbs,
        codes: [84],
        keys: 't',
        label: 'Change Thumbnails',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: hideDialog,
        codes: [27],
        keys: 'esc',
        label: 'Hide Dialog Window',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: prevImage,
        codes: [37, 38],
        keys: '← ↑',
        label: 'Previous Image',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: nextImage,
        codes: [39, 40],
        keys: '→ ↓',
        label: 'Next Image',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: toggleAutoplay,
        codes: [32],
        keys: '[space]',
        label: 'Start/Stop Autoplay',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    }
];
var settings = {
    'autoplay': {
        name: 'autoplayDelay',
        label: 'Auto Play Delay',
        hint: 'in seconds',
        type: 'integer',
        size: 3,
        min: 0,
        max: null,
        def: 2
    },
    'inifiteScrolling': {
        name: 'inifiteScrolling',
        label: 'Infinite Scrolling',
        type: 'boolean',
        def: true
    },
    'mode': {
        name: 'galleryMode',
        label: 'Gallery Mode',
        type: 'select',
        opts: ['scrolling', 'slideshow'],
        def: 'slideshow'
    },
    'pagination': {
        name: 'imageLimit',
        label: 'Pagination Limit',
        hint: 'use <b>0</b> to disable',
        type: 'integer',
        size: 3,
        min: 0,
        max: null,
        def: 0
    },
    'preloading': {
        name: 'preloadingEnabled',
        label: 'Preloading reach',
        hint: 'use <b>0</b> to disable preloading',
        type: 'integer',
        size: 3,
        def: 10
    },
    'minLoadingTime': {
        name: 'autoplayDelay',
        label: 'Min loading time',
        hint: 'in ms',
        type: 'integer',
        size: 4,
        min: 0,
        max: null,
        def: 4
    },
    'theme': {
        name: 'theme',
        label: 'Gallery Theme',
        type: 'select',
        opts: ['default', 'classic', 'green', 'blue'],
        def: 'default'
    },
    'thumbnails': {
        name: 'showThumbs',
        label: 'Show Thumbnails',
        type: 'boolean',
        def: true
    },
    'thumbnailsSize': {
        name: 'thumbnailsSize',
        label: 'Thumbnails Size',
        type: 'select',
        opts: ['small', 'medium', 'large'],
        def: 'medium'
    }
};

// objects for features that need multiple settings and calculated properties
var autoplay = {
    active: false,
    count: parseInt(GM_getValue(settings.autoplay.name, settings.autoplay.def), 10),
    delay: parseInt(GM_getValue(settings.autoplay.name, settings.autoplay.def), 10),
    paused: false,
    timer: undefined
};
var pagination = {
    append: GM_getValue(settings.inifiteScrolling.name, settings.inifiteScrolling.def),
    active: false,
    limit: parseInt(GM_getValue(settings.pagination.name, settings.pagination.def), 10),
    page: parseInt(getHashParam('page'), 10) || 1
};
var preloading = {
    active: GM_getValue(settings.preloading.name, settings.preloading.def) > 0,
    pos: activeImage,
    reach: parseInt(GM_getValue(settings.preloading.name, settings.preloading.def), 10),
};


/*  ===== Core Functions =====
 Where the magic happens...
 */

/**
 * Crawls the normal gallery page and finds all thumbnail images
 * @return  Array   locations of all thumbnail images
 */
function findImages() {
    var imgs = document.getElementById('gallery').getElementsByTagName('img');
    var thumbRegex = /^(.*\/images\/)(thumb)(\/.*\/)(\d+)(\..*)$/i;

    var count = 0;
    var ret = [];
    var match;
    var image;

    for (var i = 0; i < imgs.length; i++) {
        if (thumbRegex.test(imgs[i].src)) {
            match = thumbRegex.exec(imgs[i].src);
            image = {
                id: match[4],
                pos: count++,
                thumb: match[0],

                loadedUrl: null,
                singlePageUrl: imgs[i].parentNode.href,
                fullUrlRequest: null,
                callback: () => {
                },
                full(callback) {
                    if (this.loadedUrl) {
                        callback(this.loadedUrl);
                    } else {
                        let old = this.callback;
                        this.callback = url => {
                            old(url);
                            callback(url);
                        }
                        fetchFullImageUrl(this);
                    }
                }
            };
            ret.push(image);
            imagesMap.set(image.id, image);
        }
    }

    totalImages = ret.length;
    return ret;
}

function fetchFullImageUrl(image) {
    var fullImgRegex = /^(.*\/images\/)(full)(\/.*\/)(\d+)(\..*)$/i;
    var xmlHttp = new XMLHttpRequest();
    xmlHttp.onreadystatechange = function () {
        if (xmlHttp.readyState === 4 && xmlHttp.status === 200) {
            var el = document.createElement( 'html' );
            el.innerHTML = xmlHttp.responseText;
            let srcs = el.querySelector( '#navigation')
                .getElementsByTagName('a')

            let requestedReference
            for (let i in srcs) {
                let href = srcs[i].href;
                let match = fullImgRegex.exec(href);
                if (match && match.length >= 2) {
                    let id = match[4];
                    if (id === image.id) {
                        requestedReference = href;
                    }
                    let otherImage = imagesMap.get(id);
                    if (otherImage && !otherImage.loadedUrl) {
                        otherImage.loadedUrl = href;
                        if (otherImage.callback) {
                            otherImage.callback(otherImage.loadedUrl);
                        }
                    }
                }
            }
            if (requestedReference) {
                image.loadedUrl = requestedReference;
                if (image.callback) {
                    image.callback(image.loadedUrl)
                }
            }
        }
    };
    xmlHttp.open("GET", image.singlePageUrl, true); // true for asynchronous
    xmlHttp.send(null);
    return xmlHttp;
}

function smartUrlLoading() {

}

/**
 * Sets the global variables needed for pagination in other functions
 */
function initPagination() {
    initSetting('pagination');

    // reset pagination object properties to default
    pagination = {
        append: settings.inifiteScrolling.value,
        active: (settings.mode.value === 'slideshow'),
        limit: parseInt(settings.pagination.value, 10),
        page: parseInt(getHashParam('page'), 10) || 1
    };

    if ((pagination.limit <= 0) || (settings.mode.value === 'slideshow')) {
        // in slideshow mode pagination is handled as if it is disabled
        pagination.active = false;
        pagination.limit = totalImages;
        pagination.page = 1;
    } else {
        pagination.active = true;
        if (!pagination.page || (pagination.page < 1))
            pagination.page = 1;
    }

    // ensure the user is on the correct page
    var page = findImagePage();
    if (page !== pagination.page) {
        setHashParam('page', page);
        pagination.page = page;
    }
}

/**
 * Finds the page number that the active image should be on
 * @param   Int     (Optional) The number of the image to find (default: value of activeImage internal variable)
 * @return  Int     The page number containing the active image
 */
function findImagePage(pos) {
    if ((typeof activeImage === 'undefined') || !activeImage || (activeImage <= 0))
        return 1;

    return parseInt(((activeImage / pagination.limit) + 1), 10);
}

/**
 * Returns the images that will be used on the current page
 * @param   Array   Objects representing all images from the original gallery
 * @return  Array   Multi-dimensional array of objects representing all images on each page
 */
function paginateImages(imgs) {
    // if images have been paginated previously, they must first be un-paginated
    if (typeof imgs[0] === 'object' && typeof imgs[0][0] === 'object')
        imgs = resetImages(imgs);

    var page = 0;
    var ret = [];
    for (var i = 0; i < imgs.length; i++) {
        if (typeof ret[page] === 'undefined')
            ret[page] = [];
        ret[page].push(imgs[i]);

        if (((i + 1) % pagination.limit) === 0)
            page++;
    }
    return ret;
}

/** Flattens a paginated multi-dimensional array of images
 * @param   Array   Multi-dimensional array of objects representing all images on multiple page
 * @return  Array   Multi-dimensional array of objects representing all images on one page
 */
function resetImages(imgs) {
    var ret = [];
    for (var i = 0; i < imgs.length; i++) {
        for (var j = 0; j < imgs[i].length; j++) {
            ret.push(imgs[i][j]);
        }
    }
    // if the supplied array was only 1-dimensional, then the new array will be empty
    if (ret.length === 0)
        ret = imgs;
    return ret;
}

/**
 * Loads the next "page" of images in Infinite Scrolling mode
 * Updates the position text and the pagination links
 */
function loadNextPage() {
    if (pagination.page < images.length) {
        pagination.page++;
        showNotification('Loading images from page ' + pagination.page);

        updatePosition(undefined, (pagination.limit * pagination.page), undefined, undefined);
        updatePagination(pagination.page);

        populateScrollingGallery(images[(pagination.page - 1)], false);
        populateThumbnails(images[(pagination.page - 1)], false);

        setHashParam('page', pagination.page);
    } else {
        var loader = document.getElementById('loader');
        if (loader)
            loader.parentNode.removeChild(loader);
    }
}

/**
 * Generates HTML for the help dialog
 * @return  String  HTML to be placed in the dialog
 */
function getAbout() {
    var about = '';
    about += '<p>' + GM_info.script.name + ' v' + GM_info.script.version + '. Automatic script updates are ' + ((GM_info.scriptWillUpdate) ? 'enabled' : 'disabled') + '. </p>';
    about += '<p>' + GM_info.script.description + '</p>';

    return about;
}

/**
 * Generates HTML for the help dialog
 * @return  String  HTML to be placed in the dialog
 */
function getHelp() {
    var help = '';
    help += '<table>';
    help += '<tr><th></th><th>Hotkeys</th></tr>';
    for (var h in hotkeys)
        help += '<tr><td class="key"><b>' + hotkeys[h].keys + '</b></td><td class="command">' + hotkeys[h].label + '</td></tr>';

    help += '</table>';

    return help;
}

/**
 * Generates the DOM objects needed for the thumbnail images
 * @param   Array   The objects of all images to be displayed
 * @param   Bool    (Optional) If the existing thumbnails should be removed (default: false)
 */
function populateThumbnails(imgs, reset) {
    reset = (typeof reset === "undefined") ? false : reset;

    var thumbs = document.getElementById('thumbnails');
    if (!thumbs)
        return false;

    if (reset)
        thumbs.innerHTML = '';
    thumbs.className = settings.thumbnailsSize.value;

    var img, link;
    for (var i = 0; i < imgs.length; i++) {
        img = document.createElement('img');
        img.src = imgs[i].thumb;

        link = document.createElement('a');
        link.addEventListener('click', clickThumbnail);
        link.className = 'thumbnail';
        link.id = 'thumb_' + imgs[i].pos;
        link.rel = imgs[i].pos;
        link.appendChild(img);

        thumbs.appendChild(link);
    }
}

/**
 * Generates the DOM objects needed for the scrolling mode
 * @param   Array   The objects of all images to be displayed
 * @param   Bool    (Optional) If the existing gallery images should be removed (default: false)
 */
function populateScrollingGallery(imgs, reset) {
    reset = (typeof reset === "undefined") ? false : reset;

    var gallery = document.getElementById('gallery');
    if (!gallery)
        return false;

    if (reset)
        gallery.innerHTML = '';

    var container, spinner;
    for (var i = 0; i < imgs.length; i++) {
        const img = document.createElement('img');
        img.alt = '';
        img.rel = imgs[i].pos;
        imgs[i].full(url => img.src = url);

        spinner = document.createElement('span');
        spinner.className = 'spinner';

        const link = document.createElement('a');
        link.className = 'image';
        imgs[i].full(url => link.href = url);
        link.id = 'full_' + (((pagination.page - 1) * pagination.limit) + i);

        link.appendChild(img);
        link.appendChild(spinner);

        container = document.createElement('p');
        container.appendChild(link);

        gallery.appendChild(container);
    }

    if (pagination.append && (imgs.length < totalImages)) {
        var loader = document.getElementById('loader');
        if (!loader) {
            loader = document.createElement('a');
            loader.id = 'loader'
            loader.innerHTML = 'Load Next Page of Images';
            loader.addEventListener('click', loadNextPage);
        }
        gallery.appendChild(loader);
    }
}

/**
 * Generates the DOM objects needed for the slideshow mode
 * @param   Object  The object representing the active image
 */
function buildSlideshowGallery(active) {
    var gallery = document.getElementById('gallery');
    if (!gallery)
        return false;

    gallery.innerHTML = '';

    var prev = document.createElement('a');
    prev.addEventListener('click', prevImage);
    prev.id = 'prev';
    prev.className = 'nav';
    prev.innerHTML = '<span class="arrow"><</span>';

    var next = document.createElement('a');
    next.addEventListener('click', nextImage);
    next.id = 'next';
    next.className = 'nav';
    next.innerHTML = '<span class="arrow">></span>';

    var img = document.createElement('img');
    img.alt = '';
    img.id = 'slideshowImage';
    active.full(url => img.src = url);

    var spinner = document.createElement('span');
    spinner.className = 'spinner';

    var link = document.createElement('a');
    link.className = 'image';
    active.full(url => link.href = url);
    link.id = 'slideshowLink';

    link.appendChild(img);
    link.appendChild(spinner);

    gallery.appendChild(link);
    gallery.appendChild(prev);
    gallery.appendChild(next);

    // resize the slideshow area
    resizeSlideshowGallery();
}

/**
 * Resizes the DOM object containing the slideshow image to accomodate non-fixed header and footer sizes
 */
function resizeSlideshowGallery() {
    var content = document.getElementById('content');
    if (!content)
        return false;

    // header
    var header = document.getElementById('header');
    content.style.top = (header) ? header.offsetHeight + 'px' : '';

    // footer
    var footer = document.getElementById('footer');
    content.style.bottom = (footer) ? footer.offsetHeight + 'px' : '';
}

/**
 * Generates the DOM objects for the header of rebuilt pages
 */
function buildHeader() {
    var header = document.getElementById('header');
    if (!header)
        return false;

    header.innerHTML = '';

    // logo
    var logo = document.createElement('a');
    logo.href = window.location.protocol + '//' + window.location.host;
    logo.id = 'logo';
    logo.innerHTML = '<span class="image">Image</span><span class="fap">Fap</span>';
    header.appendChild(logo);

    // heading
    var heading = document.createElement('h2');
    heading.id = 'heading';
    heading.innerHTML = '"<span class="title">' + stripslashes(title) + '</span>"';
    if (author.name && author.url)
        heading.innerHTML += ' <small>by</small> <a href="' + author.url + '">' + unescape(stripslashes(author.name)) + '</a>';
    header.appendChild(heading);

    // description
    if (desc) {
        var description = document.createElement('p');
        description.id = 'description';
        description.innerHTML = stripslashes(desc);
        header.appendChild(description);

        // if the description spans multiple lines, left-align the text
        if (description.offsetHeight > 16)
            description.style.textAlign = 'left';
    }

    // sub-heading
    var subheading = document.createElement('p');
    header.appendChild(subheading);
    // sub-heading > position
    var position = document.createElement('span');
    position.className = settings.mode.value;
    position.id = 'position';
    subheading.appendChild(position);
    // sub-heading > spacer
    var separator = document.createElement('span');
    separator.innerHTML = ' | ';
    subheading.appendChild(separator);
    // sub-heading > "toggle thumbnails" link
    var toggle = document.createElement('a');
    toggle.addEventListener('click', toggleThumbs);
    toggle.innerHTML = 'Toggle Thumbnails';
    subheading.appendChild(toggle);
    // sub-heading > spacer
    var separator = document.createElement('span');
    separator.innerHTML = ' | ';
    subheading.appendChild(separator);
    // sub-heading > "change settings" link
    var openSettings = document.createElement('a');
    openSettings.addEventListener('click', changeSettings);
    openSettings.innerHTML = 'Change Settings';
    subheading.appendChild(openSettings);

    // pagination
    if (settings.mode.value === 'scrolling' && pagination.active)
        buildPagination('header');

    // search form
    var form = document.createElement('form');
    form.id = 'search';
    form.method = 'POST';
    form.action = window.location.protocol + '//' + window.location.host + '/gallery.php';
    header.appendChild(form);
    // search form > text input
    var search = document.createElement('input');
    search.type = 'text';
    search.value = 'Enter search term(s)...';
    search.name = 'search';
    search.addEventListener('focus', function () {
        if (this.value === 'Enter search term(s)...') this.value = '';
    });
    search.addEventListener('blur', function () {
        if (this.value === '') this.value = 'Enter search term(s)...';
    });
    form.appendChild(search);
    // search form > submit button
    var submit = document.createElement('input');
    submit.type = 'submit';
    submit.value = 'Search';
    submit.name = 'submit';
    form.appendChild(submit);
}

/**
 * Generates the DOM objects for the footer of rebuilt pages
 */
function buildFooter() {
    var footer = document.getElementById('footer');
    if (!footer)
        return false;

    footer.innerHTML = '';

    footer.appendChild(addFavLink);

    if (settings.mode.value === 'scrolling' && pagination.active)
        buildPagination('footer');

    buildInfo();
    buildAutoplay();
}

/**
 * Updates the HTML for the position of the current image(s) within the gallery
 * @param   Int     (Optional) The lower limit image number (default: use existing value from DOM)
 * @param   Int     (Optional) The upper limit image number (default: use existing value from DOM)
 * @param   Int     (Optional) The total image number (default: use existing value from DOM)
 * @param   Int     (Optional) The active image number (default: use existing value from DOM)
 */
function updatePosition(lower, upper, total, active) {
    var position = document.getElementById('position');
    if (!position)
        return false;

    if (position.className !== settings.mode.value) {
        position.className = settings.mode.value;
        position.innerHTML = '';
    }

    if (settings.mode.value === 'scrolling') {
        if (position.innerHTML === undefined || position.innerHTML === '')
            position.innerHTML = 'Viewing image(s) <span id="position_lower">' + lower + '</span>-<span id="position_upper">' + upper + '</span> of <span id="position_total">' + total + '</span>';

        lower = (typeof lower === "undefined") ? parseInt(document.getElementById('position_lower').innerHTML, 10) : lower;
        upper = (typeof upper === "undefined") ? parseInt(document.getElementById('position_upper').innerHTML, 10) : upper;
        total = (typeof total === "undefined") ? parseInt(document.getElementById('position_total').innerHTML, 10) : total;
        if (upper > totalImages)
            upper = totalImages;
        document.getElementById('position_lower').innerHTML = lower;
        document.getElementById('position_upper').innerHTML = upper;
        document.getElementById('position_total').innerHTML = total;
    } else if (settings.mode.value === 'slideshow') {
        if (position.innerHTML === undefined || position.innerHTML === '')
            position.innerHTML = 'Viewing image <span id="position_active">' + lower + '</span> of <span id="position_total">' + total + '</span>';

        active = (typeof active === "undefined") ? parseInt(document.getElementById('position_active').innerHTML, 10) : active;
        total = (typeof total === "undefined") ? parseInt(document.getElementById('position_total').innerHTML, 10) : total;
        document.getElementById('position_active').innerHTML = active;
        document.getElementById('position_total').innerHTML = total;
    }
}

/**
 * Generates the DOM objects for the pagination of rebuilt gallery pages
 * @param   String  The ID of the DOM object of which to insert the pagination controls
 */
function buildPagination(location) {
    var container = document.getElementById(location);
    if (!container)
        return false;

    var pages = Math.ceil(totalImages / pagination.limit);

    if (pages <= 1)
        return false;

    var wrapper = document.createElement('div');
    wrapper.className = 'pagination';

    // previous page
    var prev = document.createElement('a');
    prev.innerHTML = '&laquo; Prev';
    if (pagination.page > 1) {
        prev.addEventListener('click', clickPagination);
        prev.rel = (pagination.page - 1);
    } else {
        prev.className = 'disabled';
    }
    wrapper.appendChild(prev);

    // individual pages
    var link, lower, upper;
    for (var i = 1; i <= pages; i++) {
        link = document.createElement('a');
        link.rel = i;

        lower = (pagination.limit * (i - 1) + 1);
        upper = (i < pages) ? (pagination.limit * i) : totalImages;
        link.innerHTML = (lower === upper) ? lower : lower + '-' + upper;

        if (i === pagination.page) {
            link.className = 'current';
        } else {
            link.addEventListener('click', clickPagination);
        }
        wrapper.appendChild(link);
    }

    // next page
    var next = document.createElement('a');
    next.innerHTML = 'Next &raquo;';
    if (pagination.page < pages) {
        next.addEventListener('click', clickPagination);
        next.rel = (pagination.page + 1);
    } else {
        next.className = 'disabled';
    }
    wrapper.appendChild(next);

    container.appendChild(wrapper);
}

/**
 * Updates the pagination controls
 * @param   Int     The number of the current page
 * @param   Bool    (Optional) If all pagination controls should be reset (default: false)
 */
function updatePagination(page, reset) {
    reset = (typeof reset === "undefined") ? false : reset;

    var containers = document.getElementsByClassName('pagination');
    if (containers.length === 0)
        return false;

    var links, rel;
    for (var i = 0; i < containers.length; i++) {
        links = containers[i].getElementsByTagName('a');

        // first handle all of the inner links (i.e. the numbered ones)
        if (reset) {
            // this is for when a page is loaded by itself
            // activate the target link and reset all of the other links to default
            for (var j = 1; j < (links.length - 1); j++) {
                if (j === page) {
                    links[j].className = 'current';
                    links[j].removeEventListener('click', clickPagination);
                } else {
                    links[j].className = '';
                    links[j].addEventListener('click', clickPagination);
                }
            }
        } else {
            // this is for when a page is appended
            // simply activate the target link
            links[page].className = 'current';
            links[page].removeEventListener('click', clickPagination);
        }

        // the first link is the "Prev" link, which needs to point to the page BEFORE the current page
        var prev = links[0];
        if (prev.nextSibling && prev.nextSibling.className === 'current') {
            prev.rel = page;
            prev.className = 'disabled';
            prev.removeEventListener('click', clickPagination);
        } else {
            prev.rel = (page - 1);
            prev.className = trim(links[0].className.replace('disabled', ''));
            prev.addEventListener('click', clickPagination);
        }

        // the last link is the "Next" link, which needs to point to the page AFTER the current page
        var next = links[(links.length - 1)];
        if (next.previousSibling && next.previousSibling.className === 'current') {
            next.rel = page;
            next.className = 'disabled';
            next.removeEventListener('click', clickPagination);
        } else {
            next.rel = (page + 1);
            next.className = trim(next.className.replace('disabled', ''));
            next.addEventListener('click', clickPagination);
        }
    }
}

/**
 * Generates the DOM objects for the information text at the bottom of the footer
 */
function buildInfo() {
    var footer = document.getElementById('footer');
    if (!footer)
        return false;

    var info = document.createElement('p');
    info.id = 'info';

    // "about" link
    var about = document.createElement('a');
    about.addEventListener('click', displayAbout);
    about.innerHTML = GM_info.script.name + ' v' + GM_info.script.version;
    info.appendChild(about);

    // spacer
    var separator = document.createElement('span');
    separator.innerHTML = ' | ';
    info.appendChild(separator);

    // "help" link
    var help = document.createElement('a');
    help.addEventListener('click', displayHelp);
    help.innerHTML = 'Help (?)';
    info.appendChild(help);

    // spacer
    var separator = document.createElement('span');
    separator.innerHTML = ' | ';
    info.appendChild(separator);

    // "settings" link
    var openSettings = document.createElement('a');
    openSettings.addEventListener('click', changeSettings);
    openSettings.innerHTML = 'Settings';
    info.appendChild(openSettings);

    footer.appendChild(info);
}

/**
 * Generates the DOM objects for the autoplay indicator in the footer
 */
function buildAutoplay() {
    var footer = document.getElementById('footer');
    if (!footer)
        return false;

    var autoplay = document.createElement('div');
    autoplay.id = 'autoplay';
    if (settings.mode.value === 'scrolling') {
        var disabled = document.createElement('span');
        disabled.innerHTML = 'Autoplay is only available in slideshow mode';
        autoplay.appendChild(disabled);
    } else if (settings.mode.value === 'slideshow') {
        // autoplay counter
        var counter = document.createElement('span');
        counter.id = 'counter';
        counter.innerHTML = (autoplay.count > 0) ? 'Advancing image in ' + autoplay.count + ' seconds' : 'Autoplay is disabled';
        autoplay.appendChild(counter);

        // spacer
        var separator = document.createElement('span');
        separator.innerHTML = ' | ';
        autoplay.appendChild(separator);

        // autplay control link
        var control = document.createElement('a');
        control.addEventListener('click', toggleAutoplay);
        control.id = 'control';
        control.innerHTML = 'Start Autoplay';
        autoplay.appendChild(control);
    }

    footer.appendChild(autoplay);
}

/**
 * Clears out and re-initializes the DOM with the basic HTML needed for the gallery
 */
function initDOM() {
    document.removeChild(document.getElementsByTagName('html')[0]);

    var html = document.createElement('html');
    head = document.createElement('head');
    body = document.createElement('body');
    html.appendChild(head);
    html.appendChild(body);
    document.appendChild(html);

    head.innerHTML = '<title>' + stripslashes(title) + '</title>';

    // build the basic HTML structure to prevent missing elements
    body.innerHTML = '<div id="header"></div><div id="content"><div id="gallery"></div></div><div id="footer"></div>'
}

/**
 * Registers the GreaseMonkey Menu commands
 * Must be run after the gallery page is initially built
 */
function initMenuCommands() {
    GM_registerMenuCommand('[' + GM_info.script.name + '] Help', displayHelp);
    GM_registerMenuCommand('[' + GM_info.script.name + '] About', displayAbout);
    GM_registerMenuCommand('[' + GM_info.script.name + '] Settings', changeSettings);
}

/**
 * Rebuilds the actual gallery page piece by piece
 */
function rebuildGalleryPage() {
    hideDialog();
    // re-initialize all settings and feature packages
    initSettings();
    initAutoplay();
    initPagination();
    initPreloading();

    // manually remove existing CSS and apply the chosen theme's CSS
    var styles = head.getElementsByTagName('style');
    for (var i = 0; i < styles.length; i++) {
        head.removeChild(styles[i]);
    }
    GM_addStyle(getCSS());

    // paginate the images using current pagination settings
    images = paginateImages(images);

    // header
    var header = document.getElementById('header');
    if (!header) {
        header = document.createElement('div');
        header.id = 'header';
        body.appendChild(header);
    }
    buildHeader();

    // thumbnails
    var thumbs = document.getElementById('thumbnails');
    if (!thumbs) {
        thumbs = document.createElement('div');
        thumbs.id = 'thumbnails';
        header.appendChild(thumbs);
    }

    // content (gallery wrapper)
    var content = document.getElementById('content');
    if (!content) {
        content = document.createElement('div');
        content.id = 'content';
        body.appendChild(content);
    }

    // main gallery
    var gallery = document.getElementById('gallery');
    if (!gallery) {
        gallery = document.createElement('div');
        gallery.id = 'gallery';
        content.appendChild(gallery);
    }

    // footer
    var footer = document.getElementById('footer');
    if (!footer) {
        footer = document.createElement('div');
        footer.id = 'footer';
        body.appendChild(footer);
    }
    buildFooter();

    // populate the thumbnails
    if (settings.thumbnails.value === false)
        thumbs.style.display = 'none';
    if (settings.mode.value === 'slideshow')
        thumbs.addEventListener('DOMMouseScroll', scrollThumbs, false);
    populateThumbnails(images[(pagination.page - 1)], true);

    // populate the gallery
    if (settings.mode.value === 'scrolling') {
        gallery.style.marginBottom = (footer.offsetHeight + 10) + 'px';
        body.className = 'scrolling';
        var lower = ((pagination.limit * (pagination.page - 1)) + 1);
        var upper = (pagination.limit * pagination.page);
        updatePosition(lower, upper, totalImages, undefined);
        populateScrollingGallery(images[(pagination.page - 1)], true);
    } else if (settings.mode.value === 'slideshow') {
        body.className = 'slideshow';
        updatePosition(undefined, undefined, totalImages, activeImage);
        buildSlideshowGallery(images[(pagination.page - 1)][activeImage]);
        refreshNavigation();
    }

    // remove the page hash for slideshow mode
    if (settings.mode.value === 'slideshow')
        unsetHashParam('page');

    // show the active image, unless it is the first image of a scrolling gallery
    if ((settings.mode.value === 'slideshow') || (activeImage > 0))
        showImage();

    // start preloading images if preloading is enabled
    if ((settings.mode.value === 'slideshow') && preloading.active)
        preloadImage();
}

/**
 * main function; executes functionality based on page (via URL)
 * Redirects to the one-page version of galleries (if not already in one-page mode)
 */
function init() {
    // prevent the initialization function from running multiple times
    if (loaded) {
        return false;
    } else {
        loaded = true;
    }

    var loc = window.location.href;
    if ((loc.indexOf('/gallery/') !== -1) || (loc.indexOf('/pictures/') !== -1)) {
        // this is a gallery page; make sure it is in "One Page" mode and then go to work
        if (loc.indexOf('view') === -1) {
            window.location.href += ((loc.indexOf('?') === -1) ? '?' : '&') + 'view=2';
        } else {
            // populate the global variables from the original gallery page
            title = document.title;
            if (title)
                title = trim(title.replace('Porn pics of ', '').replace(' (Page 1)', ''));

            var links = document.getElementsByTagName('a');
            if (links) {
                var i = 0;
                for (var i = 0; i < links.length; i++) {
                    if (links[i].href.indexOf('profile.php?') !== -1) {
                        author.name = links[i].href.split('=')[1];
                        author.url = links[i].href;
                        break;
                    }
                }
            }

            desc = document.getElementById('cnt_description');
            if (desc)
                desc = trim(desc.textContent);

            addFavLink = document.getElementById('favorites_container');

            // FINALLY! grab the images, build the gallery, enable the hotkeys, and listen for changes to settings
            images = findImages();
            initDOM();
            rebuildGalleryPage();
            initMenuCommands();
            checkSettings();    // must run after the gallery page is built (otherwise the notification gets wiped out)
            document.addEventListener('keydown', onKeyDown, false);
            window.addEventListener('focus', onWindowFocus, false);
            window.addEventListener('scroll', onWindowScroll, false);
            window.addEventListener('mozfullscreenchange', onFullScreenChange, false);
            window.addEventListener('webkitfullscreenchange', onFullScreenChange, false);
        }
    } else {
        // this might be a page with links to galleries; change all of the links to galleries to "One Page" mode
        var links = document.getElementsByTagName('a');
        if (links) {
            for (var i = 0; i < links.length; i++) {
                if ((links[i].href.indexOf('gallery') !== -1) || (links[i].href.indexOf('pictures') !== -1)) {
                    links[i].href += ((links[i].href.indexOf('?') === -1) ? '?' : '&') + 'view=2';
                }
            }
        }
    }
}

/**
 * Toggles visibility of the navigational arrows by adding/removing a "disabled" class
 */
function refreshNavigation() {
    var nav = document.getElementsByClassName('nav');
    for (var i = 0; i < nav.length; i++) {
        nav[i].className = trim(nav[i].className.replace('disabled', ''));
    }

    if (activeImage === 0) {
        var prev = document.getElementById('prev');
        if (prev)
            prev.className += ' disabled';
    } else if (activeImage === (pagination.limit - 1)) {
        var next = document.getElementById('next');
        if (next)
            next.className += ' disabled';
    }
}

/**
 * Handles the click event for the pagination links
 * @param   Event   The click event
 */
function clickPagination(evt) {
    if (this.rel) {
        var page = parseInt(this.rel, 10);
        activeImage = (((page - 1) * pagination.limit) + 1);
        unsetHashParam('image');
        updatePagination(page, true);
        goToPage(page);
    }
}

/**
 * Sets the values needed to change pages and rebuilds the gallery for the specified page
 * @param   Int     The number of the page to display
 */
function goToPage(page) {
    pagination.page = page;

    var lower = ((pagination.limit * (pagination.page - 1)) + 1);
    var upper = (pagination.limit * pagination.page);

    setHashParam('page', page);
    setHashParam('image', lower);

    updatePosition(lower, upper, totalImages, undefined);

    populateScrollingGallery(images[(page - 1)], true);
    populateThumbnails(images[(page - 1)], true);

    window.scrollTo(0, 0);
}

/**
 * Captures mouse scrolling on the thumbnails and scrolls the images horizontally
 * @param   Event   mouse wheel scroll event
 */
function scrollThumbs(evt) {
    if (!evt)
        evt = this;
    evt.preventDefault();   // prevent vertical scrolling on the page

    var delta = (evt.detail) ? evt.detail : 0;
    window.document.getElementById('thumbnails').scrollLeft += (delta * 10);

    evt.returnValue = false;
}

/**
 * Sets the active image based on the thumbnail image that was clicked
 * Expects to be executed as a click event for a DOM object with a "rel" value
 * @param   Event   The click event
 */
function clickThumbnail(evt) {
    if (this.rel) {
        activeImage = parseInt(this.rel, 10);
        preloading.pos = activeImage;
    }
    showImage();
}

/**
 * Sets the active image to the next image
 * Ensures the active image is not the last one already
 */
function nextImage() {
    if (settings.mode.value === 'slideshow' && autoplay.active) {
        autoplay.count = autoplay.delay;
    }

    if (activeImage < ((pagination.page * pagination.limit) - 1)) {
        activeImage++;
        showImage();
    }
}

/**
 * Sets the active image to the previous image
 * Ensures the active image is not the first one already
 */
function prevImage() {
    if (settings.mode.value === 'slideshow' && autoplay.active) {
        stopAutoplay();
    }

    if (activeImage > ((pagination.page - 1) * pagination.limit)) {
        activeImage--;
        showImage();
    }
}

/**
 * Sets the internal activeImage pointer and sets the image hash parameter
 * Activates the correct thumbnail for the active image
 * @param   Int     (Optional) The number of the active image (default: value of activeImage internal variable)
 */
function setActiveImage(pos) {
    pos = (typeof pos === "undefined") ? activeImage : pos;

    setHashParam('page', findImagePage());
    setHashParam('image', (pos + 1));
    showActiveThumb(pos);
}

/**
 * Highlights the thumbnail corresponding to the current image
 * and scrolls it into view in slideshow mode
 * @param   Int     (Optional) The number of the active image (default: value of activeImage internal variable)
 */
function showActiveThumb(pos) {
    pos = (typeof pos === "undefined") ? activeImage : pos;

    var thumbs = document.getElementsByClassName('thumbnail');
    for (var i = 0; i < thumbs.length; i++) {
        thumbs[i].className = trim(thumbs[i].className.replace('active', ''));
    }

    var thumb = document.getElementById('thumb_' + pos);
    if (thumb) {
        thumb.className += ' active';
        if (settings.mode.value === 'slideshow')
            thumb.scrollIntoView();
    }
}

/**
 * Displays/navigates to the "active" image
 * In scrolling mode, this simply scrolls the page up/down to the active image,
 * but in slideshow mode, this shows the active image and hides the others
 * @param   Int     (Optional) The number of the active image (default: value of activeImage internal variable)
 */
function showImage(pos) {
    pos = (typeof pos === "undefined") ? activeImage : pos;

    setActiveImage(pos);

    if (settings.mode.value === 'scrolling') {
        var target = document.getElementById('full_' + pos);
        if (target)
            target.scrollIntoView();
    } else if (settings.mode.value === 'slideshow') {
        var link = document.getElementById('slideshowLink');
        var img = document.getElementById('slideshowImage');

        if (link && img) {
            showNotification("Loading image " + (pos + 1), 1000);
            // first blank out the current image and link target from the previous image
            // then use a slight delay to set the next image and link target;
            // this causes the loading animation to be played while the image is loaded;
            // if the delay is removed, the previous image will remain visible until the new image is loaded
            // without any indication that the image has changed and the next image is loading
            img.src = link.href = '';
            setTimeout(function () {
                    images[pagination.page - 1][pos].full(url => img.src = link.href = url);
                },
                settings.minLoadingTime.value
            );

            // always remove the autoplay function from load, and then re-add again if autoplay is still enabled
            img.removeEventListener('load', startAutoplay);
            if (autoplay.active && !autoplay.paused) {
                img.addEventListener('load', startAutoplay);
            }

            updatePosition(undefined, undefined, totalImages, (pos + 1));
            refreshNavigation();
            preloadImage();
            if (preloading.active && !preloading.done && (preloading.pos < activeImage))
                preloading.pos = activeImage;
        }
    }
}

/**
 * Event handler for keypresses
 * Used to handle hotkeys
 * @param   Event   The keypress event
 */
function onKeyDown(evt) {
    if (!evt)
        evt = this;

    if ((evt.target.nodeName === 'INPUT') || (evt.target.nodeName === 'SELECT') || (evt.target.nodeName === 'TEXTAREA'))
        return false;

    var correct = true,
        hotkey;
    for (var h in hotkeys) {
        hotkey = hotkeys[h];
        for (var c in hotkey.codes) {
            if (evt.keyCode === hotkey.codes[c]) {
                for (var m in hotkey.modif) {
                    correct = (evt[m] === hotkey.modif[m]) ? correct : false;
                }
                if (correct) {
                    evt.preventDefault();
                    return (typeof hotkey.action === 'function') ? hotkey.action.call() : eval(hotkey.action);
                }
            }
        }
    }
}

/**
 * Event handler for window scroll
 * Used to update the active image when manually scrolling in the scrolling gallery
 * and to determine if the next page of images should be loaded
 * @param   Event   The scroll event
 */
function onWindowScroll(evt) {
    if (settings.mode.value === 'scrolling') {
        var imgs = document.getElementById('gallery').getElementsByTagName('img');
        var target = 0;

        // loop backwards through the images until the currently visible image is found
        for (var i = (imgs.length - 1); i >= 0; i--) {
            var current = imgs[i].parentNode.offsetTop;

            if (document.body.scrollTop >= current) {
                target = parseInt(imgs[i].rel, 10);
                break;
            }
        }

        // only update the active image if it changed
        if (target !== activeImage) {
            activeImage = target;
            setActiveImage();
        }

        if (pagination.append) {
            var last = imgs[(imgs.length - 1)];
            if ((last.parentNode.offsetTop - window.innerHeight) <= window.pageYOffset) {
                loadNextPage();
            }
        }
    }
}

/**
 * Event handler for window focus
 * Used to monitor setting changes and refresh the gallery when needed
 * @param   Event   The focus event
 */
function onWindowFocus(evt) {
    var prevMode = settings.mode.value;
    var prevPagination = settings.pagination.value;
    initSettings();

    var refresh = false;

    var thumbs = document.getElementById('thumbnails');
    if (thumbs) {
        // thumbnails can be toggled without rebuilding the entire gallery page
        thumbs.className = settings.thumbnailsSize.value;
        thumbs.style.display = (settings.thumbnails.value === true) ? 'block' : 'none';
        if (settings.mode.value === 'slideshow')
            resizeSlideshowGallery();
    }

    if (prevMode !== settings.mode.value) {
        // current gallery mode does not match the stored preferences
        refresh = true;
    }

    if (settings.mode.value === 'scrolling') {
        if (prevPagination !== settings.pagination.value) {
            // current gallery pagination does not match the stored preferences
            refresh = true;
        }
    }

    if (refresh)
        rebuildGalleryPage();
}

/**
 * Displays the about dialog
 */
function displayAbout() {
    showDialog(getAbout(), 'About');
}

/**
 * Displays the help dialog
 */
function displayHelp() {
    showDialog(getHelp(), 'Help');
}

/**
 * Re-initialize the autoplay settings
 */
function initAutoplay() {
    if (autoplay.timer)
        window.clearTimeout(autoplay.timer);

    initSetting('autoplay');

    // reset autoplay object properties to default
    autoplay = {
        active: false,
        count: parseInt(settings.autoplay.value, 10),
        delay: parseInt(settings.autoplay.value, 10),
        paused: false,
        timer: undefined
    };
}

/**
 * Starts the autoplay used timer for advancing to the next image
 * If the delay is not set correctly, the user is prompted to set it
 */
function startAutoplay() {
    if (!autoplay.active)
        return false;

    if (autoplay.delay > 1000) {
        // the delay is likely in milliseconds, so convert it to seconds and save
        autoplay.delay /= 1000;
        GM_setValue(settings.autoplay.name, autoplay.delay);
        initAutoplay();
    }

    if (autoplay.delay < 0) {
        // the delay is invalid; inform the user
        var buttons = {0: {text: 'Change Settings', action: changeSettings}};
        showDialog('<p>The current value for the ' + settings.autoplay.label + ' is not valid.</p>', 'Invalid ' + settings.autoplay.label, buttons);
    } else {
        autoplay.count = autoplay.delay;
        if (activeImage < totalImages) {
            autoplayTimer();
        } else {
            stopAutoplay();
        }
    }
}

/**
 * Stops the autoplay timer used for advancing to the next image
 */
function stopAutoplay() {
    window.clearTimeout(autoplay.timer);
    autoplay.active = false;
    autoplay.count = autoplay.delay;

    var counter = document.getElementById('counter');
    if (counter)
        counter.innerHTML = 'Autoplay is disabled';

    var control = document.getElementById('control');
    if (control)
        control.innerHTML = 'Start Autoplay';
}

/**
 * Pauses the autoplay timer
 */
function pauseAutoplay() {
    window.clearTimeout(autoplay.timer);
    autoplay.paused = true;

    var counter = document.getElementById('counter');
    if (counter)
        counter.innerHTML = 'Autoplay is paused';

    var control = document.getElementById('control');
    if (control)
        control.innerHTML = 'Resume Autoplay';
}

/**
 * Resumes the autoplay timer
 */
function resumeAutoplay() {
    var counter = document.getElementById('counter');
    if (counter)
        counter.innerHTML = 'Resuming autoplay...';

    var control = document.getElementById('control');
    if (control)
        control.innerHTML = 'Pause Autoplay';

    autoplay.paused = false;
    autoplayTimer();
}

/**
 * Starts the counter indicator for advancing to the next image
 */
function autoplayTimer() {
    window.clearTimeout(autoplay.timer);

    var counter = document.getElementById('counter');
    if (!counter)
        return false;

    if (activeImage < (totalImages - 1)) {
        if (autoplay.count > 0) {
            autoplay.timer = window.setTimeout(autoplayTimer, 1000);
            counter.innerHTML = 'Advancing image in <b>' + autoplay.count + '</b> seconds';
        } else {
            counter.innerHTML = 'Loading image...';
            nextImage();
        }
        autoplay.count--;
    } else {
        counter.innerHTML = 'End of gallery';
    }
}

/**
 * Toggles autoplay (start and pause/resume)
 */
function toggleAutoplay() {
    autoplay.active = !autoplay.active;
    if (autoplay.active) {
        resumeAutoplay();
    } else {
        pauseAutoplay();
    }

    if (fullscreen)
        showAutoplayIndicator();
}

/**
 * Displays an indicator when toggling autoplay
 */
function showAutoplayIndicator() {
    var gallery = document.getElementById('gallery');
    if (!gallery)
        return false;

    var indicator = document.getElementById('indicator');
    if (indicator)
        hideAutoplayIndicator(0);

    indicator = document.createElement('div');
    indicator.id = 'indicator';
    indicator.style.opacity = 1;
    gallery.appendChild(indicator);

    if (autoplay.active) {
        indicator.innerHTML = '<span class="symbol play">&#9658;</span>';
    } else {
        indicator.innerHTML = '<span class="symbol pause">||</span>';
    }
    setTimeout(function () {
        hideAutoplayIndicator(100);
    }, 1000);
}

/**
 * Fades the autoplay indicator
 */
function hideAutoplayIndicator(duration) {
    duration = (typeof duration === "undefined") ? 100 : duration;

    var indicator = document.getElementById('indicator');
    if (!indicator)
        return false;

    var timer = setInterval(function () {
        if (indicator === null || indicator.parentNode === null) {
            clearInterval(timer);
        } else {
            indicator.style.opacity -= 0.1
            if (indicator.style.opacity <= 0)
                indicator.parentNode.removeChild(indicator);
        }
    }, duration);
}

/**
 * Toggles the slideshow mode for gallery pages
 * A dialog is shown after toggling to allow the user to apply the change immediately
 * @param   Bool    (Optional) If a nofitication should be displayed (default: true)
 */
function switchGalleryMode(notify) {
    notify = (typeof notify === "undefined") ? true : notify;

    if (settings.mode.value === 'slideshow')
        GM_setValue(settings.mode.name, 'scrolling');
    else if (settings.mode.value === 'scrolling')
        GM_setValue(settings.mode.name, 'slideshow');
    initSetting('mode');

    if (notify) {
        var buttons = {
            0: {text: 'Apply Change Now', action: rebuildGalleryPage},
            1: {text: 'Close', action: hideDialog}
        };
        showDialog('<p>Gallery mode has been changed to ' + settings.mode.value + '.</p>', 'Gallery Mode', buttons);
    }
}

/**
 * Toggles visibilty on thumbnails and stores the preference
 */
function toggleThumbs() {
    GM_setValue(settings.thumbnails.name, !GM_getValue(settings.thumbnails.name, settings.thumbnails.def));
    initSetting('thumbnails');

    var visible = settings.thumbnails.value;

    var thumbs = document.getElementById('thumbnails');
    if (thumbs) {
        var thumbsHeight = thumbs.offsetHeight;
        thumbs.style.display = (visible) ? 'block' : 'none';
        showNotification('Thumbnails are now ' + ((visible) ? 'visible' : 'hidden'), 1000);

        if (settings.mode.value === 'slideshow') {
            resizeSlideshowGallery();
        } else if (settings.mode.value === 'scrolling') {
            if (visible) {
                // scroll the window up to the top of the page when thumbnails are visible
                window.scrollTo(0, 0);
            } else {
                // attempt to prevent the page from scrolling too much when thumbnails are hidden
                window.scrollTo(0, (window.pageYOffset - thumbsHeight - 10));
            }
        }
    }
}

/**
 * Re-initialize the preloading settings
 */
function initPreloading() {
    initSetting('preloading');

    // reset autoplay object properties to default
    preloading = {
        active: settings.preloading.value > 0,
        pos: activeImage,
        reach: settings.preloading.value
    };

}

function performPreload(imageData) {
    const image = document.createElement('img');
    image.setAttribute('alt', "preloadin...");
    imageData.full(url => image.setAttribute('src', url));
    image.addEventListener('load', preloadImage);
}

/**
 * Preloads the next image in slideshow mode
 * Will be called continuously until the last image is loaded
 */
function preloadImage() {
    if (!preloading.active)
        return false;

    if (preloading.pos < (activeImage + preloading.reach)) {
        preloading.pos++;
        showNotification("Preloading image " + (preloading.pos + 1), 1000);

        var imageData = images[pagination.page - 1][preloading.pos];
        performPreload(imageData);
    }
}

/**
 * Toggles full screen view in supported browsers
 * Full screen view is only available in slideshow mode
 */
function toggleFullScreen() {
    if (settings.mode.value !== 'slideshow')
        return false;

    if (!document.mozFullScreenElement && !document.webkitFullscreenElement) {
        var target = document.getElementById('content');
        if (target.mozRequestFullScreen) {
            target.mozRequestFullScreen();
        } else if (target.webkitRequestFullscreen) {
            target.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
        } else {
            showDialog('Sorry, but it seems your browser does not support customized fullscreen HTML.', 'Fullscreen Error');
        }
    } else {
        if (document.mozCancelFullScreen) {
            document.mozCancelFullScreen();
        } else if (document.webkitCancelFullScreen) {
            document.webkitCancelFullScreen();
        }
    }
}

/**
 * Event handler for fullscreen enter/exit
 * Used to move the thumbnails container into the c#ontent container in fullscreen mode
 * and back to the #header container when not in fullscreen mode
 * @param   Event   fullscreenchange event
 */
function onFullScreenChange(evt) {
    fullscreen = !(document.mozFullScreenElement === null || document.webkitFullscreenElement === null);
    if (fullscreen) {
        document.getElementById('content').insertBefore(document.getElementById('thumbnails'), document.getElementById('gallery'));
    } else {
        document.getElementById('header').appendChild(document.getElementById('thumbnails'));
    }
    resizeSlideshowGallery();
}


/*  ===== Dialog Functions =====
 Rather than using boring alert() and prompt() boxes, which are finicky in Greasemonkey,
 this script creates a div, styled with CSS, to replicate that functionality.
 */

/**
 * Generates the DOM elements for the dialog boxes
 */
function buildDialog() {
    var dialog = document.getElementById('dialogBox');
    if (dialog) {
        resetDialog();
        return;
    }

    dialog = document.createElement('div');
    dialog.id = 'dialogBox';
    dialog.innerHTML = '<h3 id="dialogTitle"></h3><div id="dialogMessage"></div><div id="dialogButtons"></div>';

    var closeButton = document.createElement('a');
    closeButton.addEventListener('click', hideDialog, false);
    closeButton.id = 'dialogClose';
    closeButton.innerHTML = '&#10006;';
    dialog.appendChild(closeButton);

    var dialogContainer = document.createElement('div');
    dialogContainer.addEventListener('click', function (e) {
        hideDialog(e, true);
    }, false);
    dialogContainer.id = 'dialogContainer';
    dialogContainer.style.display = 'none';
    dialogContainer.appendChild(dialog);
    body.appendChild(dialogContainer);
}

/**
 * Hides the dialog box
 * @param   Event   The click event
 * @param   Bool    (Optional) If true, will only hide the dialog if the target of the click event is the dialogContainer
 */
function hideDialog(e, containerOnly) {
    containerOnly = (typeof containerOnly === "undefined") ? false : containerOnly;
    if (containerOnly && (typeof e !== "undefined") && (e.target.id !== 'dialogContainer'))
        return false;

    var dialogContainer = document.getElementById('dialogContainer');
    if (dialogContainer)
        dialogContainer.style.display = 'none';
}

/**
 * Vertically centers the dialog box on the screen
 */
function positionDialog() {
    var dialogBox = document.getElementById('dialogBox');
    var dialogContainer = document.getElementById('dialogContainer');

    dialogContainer.style.display = 'block';
    var top = ((window.innerHeight / 2) - (dialogBox.offsetHeight / 2) - 20);
    dialogBox.style.top = (top < 0) ? '0' : top + 'px';

    dialogContainer.scrollTop = 0;
}

/**
 * Clears out the contents of the dialog box
 */
function resetDialog() {
    document.getElementById('dialogTitle').innerHTML = '';
    document.getElementById('dialogMessage').innerHTML = '';
    document.getElementById('dialogButtons').innerHTML = '';
}

/**
 * Preloads the next image in slideshow mode
 * Will be called continuously until the last image is loaded
 * @param   String  The HTML content of the dialog box
 * @param   String  (Optional) A title for the dialog box (always prefixed with '[Script Name]')
 * @param   Object  (Optional) An object representing the buttons to display and their actions (default: close button)
 */
function showDialog(content, title, buttons) {
    buttons = (typeof buttons === "undefined") ? {0: {text: 'Close', action: hideDialog}} : buttons;
    title = (typeof title === "undefined") ? '' : title;

    buildDialog();

    var dialogContainer = document.getElementById('dialogContainer');
    var titleContainer = document.getElementById('dialogTitle');
    var messageContainer = document.getElementById('dialogMessage');
    var buttonContainer = document.getElementById('dialogButtons');

    titleContainer.innerHTML = '[<b>' + GM_info.script.name + '</b>] ' + title;
    messageContainer.innerHTML = content;

    var btn;
    for (var button in buttons) {
        btn = document.createElement('button');
        btn.addEventListener('click', buttons[button].action, false);
        btn.innerHTML = buttons[button].text;
        if (button == 0)
            btn.className = 'default';
        buttonContainer.appendChild(btn);
    }

    positionDialog();

    buttonContainer.childNodes[0].focus();
}


/*  ===== Notication Message Functions =====
 The are notification messages that display in the lower right corner and are
 automatically removed after a short duration without needing user interaction.
 */

/**
 * Generates and displays a notification message
 * @param   String  The notification message text
 * @param   Int     (Optional) The delay (in milliseconds) before the message should be hidden (default: 5000)
 * @param   Int     (Optional) The duration (in milliseconds) over which the message should fade (default: 100)
 * @return  Object  The DOM object of the created notification
 */
function showNotification(text, delay, duration) {
    delay = (typeof delay === "undefined") ? 5000 : delay;

    var notificationContainer = document.getElementById('notificationContainer');
    if (!notificationContainer) {
        notificationContainer = document.createElement('div');
        notificationContainer.id = 'notificationContainer';
        document.getElementById('content').appendChild(notificationContainer);
    }
    notificationContainer.style.bottom = (document.getElementById('footer').offsetHeight + 10) + 'px';

    var notifications = notificationContainer.getElementsByClassName('notification');

    var notification = document.createElement('div');
    notification.className = 'notification';
    notification.id = 'notification' + notifications.length;
    notification.innerHTML = text;

    if (notifications.length > 0) {
        notificationContainer.insertBefore(notification, notifications[0]);
    } else {
        notificationContainer.appendChild(notification);
    }

    if (delay > 0)
        setTimeout(function () {
            hideNotification(notification, duration);
        }, delay);

    return notification;
}

/**
 * Fades a notification message out and then removes it
 * @param   Object  The notification message to hide
 * @param   Int     (Optional) The duration (in milliseconds) over which the message should fade (default: 100)
 */
function hideNotification(notification, duration) {
    duration = (typeof duration === "undefined") ? 100 : duration;

    if (!notification)
        return false;

    notification.style.opacity = 1;
    var timer = setInterval(function () {
        notification.style.opacity -= 0.1
        if (notification.style.opacity <= 0) {
            notification.parentNode.removeChild(notification);
            clearInterval(timer);
        }
    }, duration);
}


/*  ===== Setting Functions =====
 Functions used for working with the individual settings in bulk
 */

/**
 * Saves the values for all settings from the Settings Form Dialog Box
 * Verifies values are valid based on criteria of the individual settings (min, max, etc.)
 */
function applySettings() {
    var setting, field, value;
    for (s in settings) {
        setting = settings[s];
        if (setting && (typeof setting === 'object')) {
            value = null;
            field = document.getElementById('setting-' + s);
            if (field) {
                switch (setting.type) {
                    case 'integer':
                        value = parseInt(field.value, 10);
                        if (isNaN(value)) {
                            var buttons = {0: {text: 'Try Again', action: changeSettings}};
                            showDialog('<p>A numeric value must be provided for <b>' + setting.label + '</b>.</p>', 'Error', buttons);
                            return false;
                        }
                        if ((typeof setting.min !== 'undefined') && (setting.min !== null) && (value < setting.min)) {
                            var buttons = {0: {text: 'Try Again', action: changeSettings}};
                            showDialog('<p>The value provided (<span class="error">' + value + '</span>) for <b>' + setting.label + '</b> must be greater than or equal to <b>' + setting.min + '</b>.</p>', 'Error', buttons);
                            return false;
                        }
                        if ((typeof setting.max !== 'undefined') && (setting.max !== null) && (value > setting.max)) {
                            var buttons = {0: {text: 'Try Again', action: changeSettings}};
                            showDialog('<p>The value provided for (<span class="error">' + value + '</span>) <b>' + setting.label + '</b> must be less than or equal to <b>' + setting.max + '</b>.</p>', 'Error', buttons);
                            return false;
                        }
                        break;
                    case 'select':
                        value = field.options[field.selectedIndex].value;
                        break;
                    case 'boolean':
                        value = field.checked;
                        break;
                    default:
                        value = field.value;
                        break;
                }
            }
            if (value !== null) {
                settings[s].value = value;
            }
        }
    }
    saveSettings();
    var buttons = {0: {text: 'Apply Changes Now', action: rebuildGalleryPage}, 1: {text: 'Close', action: hideDialog}};
    showDialog('<p>Your changes have been saved successfully!</p>', 'Success', buttons);
}

/**
 * Checks if settings have been saved for the current script version.
 * For new installs, a notice is displayed forcing the user to save the settings for the first time;
 * for updates, the user can view the settings or dismiss the notice.
 */
function checkSettings() {
    var lastSavedVersion = GM_getValue('lastSavedVersion', false);
    if (!lastSavedVersion) {
        var buttons = {0: {text: 'Continue', action: changeSettings}};
        showDialog('<p>Since this is your first time using ' + GM_info.script.name + ', you need to view (and modify) the settings to meet your needs.</p><p>Note that all settings have a default value set already for your convenience; you can simply click the "Save Settings" button on the next dialog box to continue.', 'First Run', buttons);
    } else if (lastSavedVersion !== GM_info.script.version) {
        GM_setValue('lastSavedVersion', GM_info.script.version);
        var buttons = {0: {text: 'Change Settings', action: changeSettings}, 1: {text: 'Close', action: hideDialog}};
        showDialog('<p>The version of this script has changed from ' + lastSavedVersion + ' to ' + GM_info.script.version + '. There may be new settings for you to utilize.', 'Version Change', buttons);
    }
}

/**
 * Initializes all settings from saved preferences
 * Uses the default values for settings that have not yet been saved
 */
function initSettings() {
    for (s in settings) {
        initSetting(s)
    }
}

/**
 * Initializes the specified setting from saved preferences
 * Uses the default value for settings that have not yet been saved
 * @param   String  name (key) of the setting to initialize
 */
function initSetting(s) {
    if (settings[s] && (typeof settings[s] === 'object')) {
        if (settings[s].name)
            settings[s].value = GM_getValue(settings[s].name, settings[s].def);

        if (settings[s].type === 'integer')
            settings[s].value = parseInt(settings[s].value, 10);
        else if ((settings[s].type === 'select') && (settings[s].opts.indexOf(settings[s].value) === -1))
            settings[s].value = settings[s].def;
    }
}

/**
 * Generates the HTML for the Settings Form that will be displayed via dialog box
 */
function changeSettings() {
    initSettings();
    var setting;
    var html = '<form id="settingsForm">';
    for (s in settings) {
        setting = settings[s];
        if (setting && (typeof setting === 'object')) {
            html += '<fieldset><legend>' + setting.label + '</legend>';
            switch (setting.type) {
                case 'integer':
                case 'text':
                    html += '<label for="setting-' + s + '">Enter a value for the ' + setting.label;
                    if (setting.hint)
                        html += ' (' + setting.hint + ')';
                    html += ':<br/><span class="default">Default value: <b>' + setting.def + '</b></span></label>';
                    html += '<input type="text" id="setting-' + s + '" name="' + s + '" value="' + setting.value + '" size="' + setting.size + '" maxlength="' + setting.size + '"/>';
                    break;
                case 'select':
                    html += '<label for="setting-' + s + '">Select a value for the ' + setting.label;
                    if (setting.hint)
                        html += ' (' + setting.hint + ')';
                    html += ':<br/><span class="default">Default value: <b>' + capitalize(setting.def) + '</b></span></label>';
                    html += '<select id="setting-' + s + '" name="' + s + '">';
                    for (opt in setting.opts) {
                        html += '<option value="' + setting.opts[opt] + '"' + ((setting.value === setting.opts[opt]) ? ' selected="selected"' : '') + '>' + capitalize(setting.opts[opt]) + '</option>';
                    }
                    html += '</select>';
                    break;
                case 'boolean':
                    html += '<input type="checkbox" id="setting-' + s + '" name="' + s + '" value="true"' + ((setting.value) ? ' checked="checked"' : '') + '/>';
                    html += '<label for="setting-' + s + '">Enable ' + setting.label;
                    if (setting.hint)
                        html += ' (' + setting.hint + ')';
                    html += '<br/><span class="default">Default value: <b>' + ((setting.def) ? 'Enabled' : 'Disabled') + '</b></span></label>';
                    break;
            }
            html += '</fieldset>';
        }
    }
    showDialog(html, 'Settings', {
        0: {text: 'Save Settings', action: applySettings},
        1: {text: 'Cancel', action: hideDialog}
    });
}

/**
 * Saves the current value for each setting to local storage
 */
function saveSettings() {
    var setting;
    for (s in settings) {
        setting = settings[s];
        if (setting && (typeof setting === 'object'))
            GM_setValue(setting.name, setting.value);
    }
    initSettings();
    GM_setValue('lastSavedVersion', GM_info.script.version);
}


/*  ===== String Functions =====
 Utility functions for manipulating strings
 */

/**
 * Escapes quotes and double-quotes in a string
 * @param   String  The string to escape
 * @return  String  The escaped string
 */
function addslashes(str) {
    return str.replace(/\\/g, '\\\\').replace(/\'/g, '\\\'').replace(/\"/g, '\\"').replace(/\0/g, '\\0');
}

/**
 * Unescapes quotes and double-quotes in a string
 * @param   String  The escaped string
 * @return  String  The string to escape
 */
function stripslashes(str) {
    return str.replace(/\\'/g, '\'').replace(/\\"/g, '"').replace(/\\0/g, '\0').replace(/\\\\/g, '\\');
}

/**
 * Removes leading and trailing whitepsace from a string
 * @param   String  The string to trim
 * @return  String  The trimmed string
 */
function trim(str) {
    return str.replace(/^\s+|\s+$/g, '');
}

/**
 * Capitalizes the first character of a string
 * @param   String  The string to capitalize
 * @return  String  The capitalized string
 */
function capitalize(str) {
    return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}


/*  ===== Hash Parameter Functions =====
 Utility functions for setting and retrieving data from the URL location hash
 */

/**
 * Returns all key/value pairs stored in the location hash
 * @return  Object  The location hash parameters indexed by key name
 */
function getHashParams() {
    var params = window.location.hash.replace('#!', '').split('/');
    var ret = [];
    for (var i = 0; i < params.length; i = i + 2) {
        if (params[(i + 1)] && params[(i + 2)])
            ret[params[(i + 1)]] = params[(i + 2)];
    }
    return ret;
}

/**
 * Returns the value of the supplied hash key
 * @param   String  The name of the hash key
 * @return  String  The value of the hash key
 */
function getHashParam(key) {
    var params = getHashParams();
    return params[key] || undefined;
}

/**
 * Sets the value of the supplied hash key
 * @param   String  The name of the hash key
 * @param   String  The value of the hash key
 */
function setHashParam(key, val) {
    var hashString = '#!';
    var params = getHashParams();
    params[key] = val;
    for (key in params) {
        hashString += '/' + key + '/' + params[key];
    }
    history.replaceState(null, null, hashString);
}

/**
 * Removes a hash key/value pair from the location hash
 * @param   String  The name of the hash key
 */
function unsetHashParam(key) {
    var current = getHashParam(key);
    if (typeof current !== 'undefined')
        history.replaceState(null, null, window.location.hash.replace('/' + key + '/' + current, ''));
}


/*  ===== Gallery CSS =====
 The CSS for the rebuilt gallery page with theme support
 */

/**
 * Generates the CSS used for the rebuilt gallery page
 * @param   String  (Optional) Additional CSS
 * @return  String  The CSS for the rebuilt gallery page
 */
function getCSS(css) {
    css = (typeof css === "undefined") ? '' : css;

    initSettings('theme');
    switch (getHashParam('theme') || settings.theme.value) {
        case 'blue':
            var bg1 = '#060D1A';
            var bg2 = '#03060D';
            var fg1 = '#557799';
            var fg2 = '#557799';
            var links = '#AABBCC';
            var accent1 = '#6699CC';
            var accent2 = '#6699CC';
            break;

        case 'classic':
            var bg1 = '#FFFFFF';
            var bg2 = '#3366CC';
            var fg1 = '#666666';
            var fg2 = '#AACCEE';
            var links = '#AACCEE';
            var accent1 = '#3366CC';
            var accent2 = '#FFFFFF';
            break;

        case 'green':
            var bg1 = '#FFFFFF';
            var bg2 = '#222222';
            var fg1 = '#888888';
            var fg2 = '#888888';
            var links = '#AAAAAA';
            var accent1 = '#33AA00';
            var accent2 = '#66CC33';
            break;

        case 'default':
        default:
            var bg1 = '#222222';
            var bg2 = '#111111';
            var fg1 = '#888888';
            var fg2 = '#888888';
            var links = '#AAAAAA';
            var accent1 = '#3380CC';
            var accent2 = '#3380CC';
            break;
    }

    /**
     * Darkens a color by a specified amount
     * @param   String  The RGB hex color code
     * @param   Int     The amount (decimal-format pertentage) by which to darken the color
     * @return  String  The color code for the darkened color
     */
    function darken(color, amount) {
        color = splitColor(color);
        var ret = [];
        for (var i = 0; i < color.length; i++) {
            ret[i] = (color[i] - Math.ceil(255 * amount));

            if (ret[i] < 0)
                ret[i] = 0;
            if (ret[i] > 255)
                ret[i] = 255;

            ret[i] = ret[i].toString(16);
            if (ret[i].length < 2)
                ret[i] = '0' + ret[i];
        }
        return '#' + ret.join('').toUpperCase();
    }

    /**
     * Lightens a color by a specified amount
     * @param   String  The RGB hex color code
     * @param   Int     The amount (decimal-format pertentage) by which to lighten the color
     * @return  String  The color code for the lightened color
     */
    function lighten(color, amount) {
        color = splitColor(color);
        var ret = [];
        for (var i = 0; i < color.length; i++) {
            ret[i] = (color[i] + Math.ceil(255 * amount));

            if (ret[i] < 0)
                ret[i] = 0;
            if (ret[i] > 255)
                ret[i] = 255;

            ret[i] = ret[i].toString(16);
            if (ret[i].length < 2)
                ret[i] = '0' + ret[i];
        }
        return '#' + ret.join('').toUpperCase();
    }

    /**
     * Converts a color code into a usable array for math-based functions
     * @param   String  The RGB hex color code
     * @return  Int[]   The array containing the decimal values of each color
     */
    function splitColor(color) {
        color = color.replace('#', '');

        var offset = Math.floor(color.length / 3);
        var ret = [];
        for (var i = 0; i < color.length; i += offset) {
            ret.push(parseInt(color.substring(i, (i + offset)), 16));
        }
        return ret;
    }

    /**
     * Returns the CSS for a vertical background gradient (with vendor-specific prefixes)
     * @param   String  The RGB hex color code of the top color
     * @param   String  The RGB hex color code of the bottom color
     * @return  String  The CSS for the background gradient
     */
    function gradient(top, bottom) {
        var ret = '';
        ret += 'background: -moz-linear-gradient(top, ' + top + ' 0%, ' + bottom + ' 100%);';
        ret += 'background: -webkit-linear-gradient(top, ' + top + ' 0%, ' + bottom + ' 100%);';
        return ret;
    }

    /**
     * Returns the CSS for rounded corners (with vendor-specific prefixes)
     * @param   String  The radius value (syntax: '#px' or '#px #px #px #px')
     * @return  String  The CSS for the rounded corners
     */
    function borderRadius(radius) {
        radius = 'border-radius: ' + radius;
        // returns: -moz-border-radius: <radius>; -webkit-border-radius: <radius>; border-radius: <radius>;
        return '-moz-' + radius + '; ' + '-webkit-' + radius + '; ' + radius + ';';
    }

    /**
     * Returns the CSS for box shadows (with vendor-specific prefixes)
     * @param   String  The shadow value (syntax: '#px #px [#px] [#px] color [inset]')
     * @return  String  The CSS for the box shadows
     */
    function boxShadow(shadow) {
        shadow = 'box-shadow: ' + shadow;
        // returns: -moz-box-shadow: <shadow>; -webkit-box-shadow: <shadow>; box-shadow: <shadow>;
        return '-moz-' + shadow + '; ' + '-webkit-' + shadow + '; ' + shadow + ';';
    }

    // basics
    css += '* { -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; margin: 0; padding: 0; }';
    css += 'body { background-color: ' + bg1 + '; color: ' + fg1 + '; font: 13px Helvetica, Arial, sans-serif; }';
    css += 'a { color: ' + links + '; cursor: pointer; text-decoration: underline; }';
    css += 'a:hover, a:hover { color: ' + lighten(links, 0.133) + '; text-decoration: none; }';
    css += 'p { margin: 0 0 10px; }';
    css += 'table { font: 13px Helvetica, Arial, sans-serif; margin: auto; width: 100%; }';

    // layout
    css += '#header { background-color: ' + bg2 + '; border-bottom: 1px solid ' + lighten(bg1, 0.066) + '; color: ' + fg2 + '; padding: 10px 0 0; text-align: center; }';
    css += '.scrolling #header { margin-bottom: 10px; }';
    css += '.slideshow #header { min-height: 60px; position: fixed; top: 0; width: 100%; z-index: 2; }';
    css += '#header .title { color: ' + accent2 + '; }';
    css += '#header small { font-variant: small-caps; }';
    css += '#header p { margin: 10px 0; }';
    css += '#header #description { margin: 10px auto; max-width: 60%; text-align: center; }';
    css += '#search { position: absolute; right: 10px; top: 10px; }';
    css += '#footer { background-color: ' + bg2 + '; border-top: 1px solid ' + lighten(bg1, 0.066) + '; color: ' + fg2 + '; min-height: 60px; padding: 10px 0; position: relative; text-align: center; }';
    css += '.scrolling #footer { margin-top: 10px; position: fixed; bottom: 0; left: 0; right: 0; }';
    css += '.slideshow #footer { bottom: 0; height: 60px; position: fixed; width: 100%; z-index: 2; }';
    css += '#favorites_container { height: 40px; line-height: 40px; margin-bottom: 0 !important; position: absolute; left: 25px; bottom: 10px; }';
    css += '#autoplay { height: 20px; line-height: 20px; position: absolute; right: 25px; bottom: 20px; }';
    css += '#info { font-size: 11px; margin: 10px 0 0; }';

    // logo
    css += '#logo { text-decoration: none; position: absolute; left: 10px; top: 10px; }';
    css += '#logo span { font: 18px "Comic Sans MS"; padding: 0 2px; }';
    css += '#logo .image { background-color: ' + bg1 + '; color: ' + accent1 + '; }';
    css += '#logo:hover .image { background-color: ' + accent1 + '; color: ' + bg1 + '; }';
    css += '#logo .fap { background-color: ' + accent1 + '; color: ' + bg1 + '; }';
    css += '#logo:hover .fap { background-color: ' + bg1 + '; color: ' + accent1 + '; }';

    // forms
    css += 'form { margin: 0 0 10px; }';
    css += 'input[type="text"], select { background: ' + bg1 + '; border: 1px solid ' + fg1 + '; color: ' + fg1 + '; margin-right: 5px; padding: 5px; }';
    css += 'input[type="text"]:focus, select:focus { border-color: ' + accent1 + '; color: ' + fg1 + '; }';
    css += 'button, input[type="button"], input[type="submit"] { background: ' + lighten(bg2, 0.066) + '; ' + gradient(lighten(bg2, 0.133), lighten(bg2, 0.066)) + '; border: 1px solid ' + lighten(bg2, 0.200) + '; ' + borderRadius('5px') + ' color: ' + links + '; cursor: pointer; margin-left: 5px; padding: 5px; ' + boxShadow('0 0 0 1px ' + bg2) + '; }';
    css += 'button:hover, input[type="button"]:hover, input[type="submit"]:hover { ' + gradient(lighten(bg2, 0.200), lighten(bg2, 0.066)) + '; border: 1px solid ' + lighten(bg2, 0.266) + '; color: ' + lighten(links, 0.066) + '; }';
    css += 'button:focus, input[type="button"]:focus, input[type="submit"]:focus { ' + gradient(lighten(bg2, 0.266), lighten(bg2, 0.066)) + '; border: 2px solid ' + lighten(bg2, 0.333) + '; color: ' + lighten(links, 0.133) + '; padding: 4px; }';

    css += '#favorites_container input[type="button"], button.default, input[type="submit"] { border: 1px solid ' + accent1 + '; color: ' + lighten(links, 0.133) + '; }';
    css += '#favorites_container input[type="button"]:hover, button.default:hover, input[type="submit"]:hover { border: 1px solid ' + lighten(accent1, 0.066) + '; color: ' + lighten(links, 0.200) + '; }';
    css += '#favorites_container input[type="button"]:focus, button.default:focus, input[type="submit"]:focus { border: 2px solid ' + lighten(accent1, 0.066) + '; color: ' + lighten(links, 0.266) + '; padding: 4px; }';

    // pagination
    css += '.pagination { margin: 0; }';
    css += '.pagination a { border: 1px solid ' + darken(links, 0.133) + '; color: ' + darken(links, 0.133) + '; display: inline-block; margin: 0 2px 10px; padding: 2px 6px; text-decoration: none; }';
    css += '.pagination a:hover { border-color: ' + links + '; color: ' + links + '; display: inline-block; margin: 0 2px; padding: 2px 6px; text-decoration: none; }';
    css += '.pagination a.current { border: 1px solid ' + accent2 + '; color: ' + accent2 + '; }';
    css += '.pagination a.disabled { border: 1px solid ' + darken(fg2, 0.266) + '; color: ' + darken(fg2, 0.133) + '; cursor: default; display: inline-block; margin: 0 2px; padding: 2px 6px; }';

    // thumbnails
    css += '#thumbnails { margin-bottom: 10px; text-align: center; z-index: 2; }';
    css += '#thumbnails .thumbnail { border: 1px solid ' + darken(links, 0.133) + '; display: inline-block; margin: 2px; padding: 4px; vertical-align: middle; }';
    css += '#thumbnails .thumbnail:hover { border-color: ' + links + '; }';
    css += '#thumbnails .thumbnail.active { border: 2px solid ' + accent2 + '; padding: 3px; }';
    css += '#thumbnails.small img { max-height: 100px; }';
    css += '#thumbnails.medium img { max-height: 150px; }';
    css += '#thumbnails.large img { max-height: 200px; }';
    css += '.slideshow #thumbnails { background-color: ' + bg2 + '; padding-bottom: 10px; overflow-y: hidden; width: 100%; white-space: nowrap; }';
    css += '.slideshow #thumbnails .thumbnail { margin: 0 5px; }';

    // gallery basics
    css += '#gallery { position: relative; text-align: center; }';
    css += '#gallery .image img { border: 1px solid ' + darken(links, 0.133) + '; padding: 4px; min-height: 100px; min-width: 100px; }';
    css += '#gallery .image:hover img { border-color: ' + accent1 + '; }';
    css += '#gallery .image .spinner { border: 10px solid ' + darken(fg2, 0.266) + '; border-left-color: ' + accent1 + '; position: absolute; left: calc(100% / 2 - 80px / 2); top: calc(100% / 2 - 80px / 2); -webkit-animation: spinning 1s infinite linear; animation: spinning 1s infinite linear; }';
    css += '#gallery .image .spinner, #gallery .image .spinner:after { ' + borderRadius('50%') + '; width: 80px; height: 80px; z-index: -1; }';
    css += '@-webkit-keyframes spinning { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } }';
    css += '@keyframes spinning { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } }';

    // scrolling gallery
    css += '.scrolling #gallery { max-width: 100%; }';
    css += '.scrolling #gallery .image { clear: both; display: inline-block; max-width: 98%; position: relative; }';
    css += '.scrolling #gallery .image img { display: inline-block; max-width: 100%; }';

    css += '#loader { background: ' + lighten(bg2, 0.066) + '; ' + gradient(lighten(bg2, 0.133), lighten(bg2, 0.066)) + '; border: 1px solid ' + accent1 + '; border-radius: 5px; ' + boxShadow('0 0 0 1px ' + bg2) + '; color: ' + lighten(links, 0.133) + '; display: inline-block; margin-top: 10px; padding: 8px 16px; text-decoration: none; }';
    css += '#loader:hover { ' + gradient(lighten(bg2, 0.200), lighten(bg2, 0.066)) + '; border-color: ' + lighten(accent1, 0.066) + '; color: ' + lighten(links, 0.200) + '; }';

    // slideshow gallery
    css += '.slideshow #content { bottom: 81px; left: 0; padding: 10px; position: absolute; right: 0; top: 81px; }';
    css += '.slideshow #gallery { height: 100%; width: 100%; overflow: hidden; }';
    css += '.slideshow #gallery .image img { bottom: 0; left: 0; margin: auto; max-height: 100%; max-width: 100%; position: absolute; right: 0; top: 0; }';
    css += '.slideshow #gallery .nav { color: #FFFFFF; display: block; text-decoration: none; opacity: 0.5; position: absolute; top: 5px; bottom: 5px; height: 100%; width: 160px; z-index: 1; }';
    css += '.slideshow #gallery .nav.disabled { display: none; }';
    css += '.slideshow #gallery #next { min-height: 60px; min-width: 60px; right: 5px; }';
    css += '.slideshow #gallery #prev { min-height: 60px; min-width: 60px; left: 5px; }';
    css += '.slideshow #gallery .arrow { display: block; font-size: 120px; height: 160px; line-height: 160px; margin-top: -80px; position: relative; text-align: center; text-shadow: 0 0 5px #000000, 0 0 20px #FFFFFF; top: 50%; }';
    css += '.slideshow #gallery .nav:hover { background-color: rgba(0,0,0,0.5); opacity: 1.0; }';
    css += '.slideshow #gallery #indicator { background-color: rgba(0,0,0,0.5); ' + borderRadius('40px') + '; display: block; position: absolute; top: 50%; left: 50%; height: 160px; margin-top: -80px; margin-left: -80px; width: 160px; z-index: 1; }';
    css += '.slideshow #gallery #indicator .symbol { color: #FFFFFF; font-size: 120px; position: relative; text-align: center; text-shadow: 0 0 5px #000000, 0 0 20px #FFFFFF; }';
    css += '.slideshow #gallery #indicator .symbol.pause { font-weight: bold; }';
    css += '.slideshow #gallery #indicator .symbol.play { line-height: 160px; }';

    // full screen:: WebKit
    css += ':-webkit-full-screen { background-color: #000000; }';
    css += '#content:-webkit-full-screen { padding: 0; top: 0 !important; bottom: 0 !important; height: 100%; width: 100%; }';
    css += '#content:-webkit-full-screen .nav { ' + borderRadius('40px') + '; height: 160px; margin-top: -80px; top: 50%; }';
    css += '#content:-webkit-full-screen .image img { border: 0; padding: 0; }';
    css += '#content:-webkit-full-screen #thumbnails { background-color: #000000; position: absolute; top: 0; }';
    // full screen:: Mozilla
    css += '#content:-moz-full-screen { padding: 0; top: 0 !important; bottom: 0 !important; height: 100%; width: 100%; }';
    css += '#content:-moz-full-screen .nav { ' + borderRadius('40px') + '; height: 160px; margin-top: -80px; top: 50%; }';
    css += '#content:-moz-full-screen .image img { border: 0; padding: 0; }';
    css += '#content:-moz-full-screen #thumbnails { background-color: #000000; position: absolute; top: 0; }';

    // add to favorites
    css += '#favorites_container table { width: auto; }';

    // notification messages
    css += '#notificationContainer { bottom: 70px; position: fixed; right: 10px; }';
    css += '.notification { background: rgba(0, 0, 0, 0.8); border: 1px solid rgba(255, 255, 255, 0.2); ' + borderRadius('5px') + '; color: rgba(255, 255, 255, 0.5); display: block; font-size: 11px; margin-top: 10px; padding: 9px; }';

    // dialog box layout
    css += '#dialogContainer { background: rgba(0,0,0,0.8); display: block; text-align: center; position: fixed; top: 0; bottom: 0; left: 0; right: 0; z-index: 3; overflow-y: auto; }';
    css += '#dialogBox { background: ' + bg1 + '; border: 1px solid ' + lighten(bg2, 0.200) + '; ' + boxShadow('0 0 20px 0 #000000') + '; color: ' + fg1 + '; display: inline-block; margin: 20px auto; min-width: 300px; text-align: left; position: relative; z-index: 10; }';
    css += '#dialogClose { border-left: 1px solid ' + lighten(bg2, 0.200) + '; color: ' + links + '; font-size: 14px; line-height: 28px; position: absolute; right: 0; text-align: center; text-decoration: none; top: 0; width: 30px; }';
    css += '#dialogClose:hover { color: ' + lighten(links, 0.200) + '; }';
    css += '#dialogTitle { background: ' + bg1 + '; ' + gradient(lighten(bg2, 0.066), bg2) + '; border-bottom: 1px solid ' + lighten(bg2, 0.200) + '; color: ' + links + '; display: block; margin: 0; padding: 5px 10px; }';
    css += '#dialogTitle b { color: ' + accent2 + '; }';
    css += '#dialogMessage { display: block; padding: 10px; }';
    css += '#dialogButtons { clear: both; display: block; padding: 10px; text-align: right; }';

    // dialog box content
    css += '#dialogMessage table { margin: 0; }';
    css += '#dialogMessage table th { color: ' + accent1 + '; font-size: 14px; text-align: left; }';
    css += '#dialogMessage table b { color: ' + lighten(accent1, 0.066) + '; }';
    css += '#dialogMessage table td.name { padding-right: 5%; text-align: right; width: 20%; }';
    css += '#dialogMessage table td.key { text-align: center; width: 25%; }';

    // dialog box form elements
    css += '#dialogBox #settingsForm { width: 820px }';
    css += '#dialogBox .error { color: #C43131; font-weight: bold; }';
    css += '#dialogBox button { margin-right: 5px; }';
    css += '#dialogBox fieldset { border: 0; border-bottom: 1px solid ' + lighten(bg2, 0.200) + '; margin: 0 0 15px; padding-bottom: 10px; min-width: 400px; }';
    css += '#dialogBox fieldset:nth-child(1n) { float: left; }';
    css += '#dialogBox fieldset:nth-child(2n) { float: right; }';
    css += '#dialogBox legend { color: ' + accent1 + '; font-weight: bold; margin-bottom: 10px; }';
    css += '#dialogBox label { float: left; line-height: 20px; }';
    css += '#dialogBox label b { color: ' + accent1 + '; }';
    css += '#dialogBox label span.default { font-size: 11px; font-variant: small-caps; }';
    css += '#dialogBox input[type="text"], #dialogBox select { float: right; }';
    css += '#dialogBox input[type="checkbox"] { float: left; margin: 4px 10px 0 ; }';

    // insert the line breaks automatically before returning
    return css.replace(/}/g, "}\n");
}