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.

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==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.7
// @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: [90],
        keys: 'z',
        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, 65, 87],
        keys: '← ↑ a w',
        label: 'Previous Image',
        modif: {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
    },
    {
        action: nextImage,
        codes: [39, 40, 68, 83],
        keys: '→ ↓ d s',
        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)) {
        showNotification("Preloading image " + (preloading.pos + 1), 1000);

        const imageData = images[pagination.page - 1][preloading.pos];
        if (imageData) {
            performPreload(imageData);
        }
        preloading.pos++;
    }
}

/**
 * Toggles full screen view in supported browsers
 * Full screen view is only available in slideshow modepreloading.pos <
 */
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");
}