Greasy Fork

Greasy Fork is available in English.

ppixiv

Better Pixiv image viewing | Download ugoira as MKV | One-click like, bookmark, follow

目前为 2018-09-21 提交的版本。查看 最新版本

// ==UserScript==
// @name        ppixiv
// @author      ppixiv
// @description Better Pixiv image viewing | Download ugoira as MKV | One-click like, bookmark, follow
// @include     http://*.pixiv.net/*
// @include     https://*.pixiv.net/*
// @run-at      document-start
// @grant       GM.xmlHttpRequest
// @grant       GM_xmlhttpRequest
// @grant       GM_setValue
// @grant       GM_getValue
// @connect     pixiv.net
// @connect     i.pximg.net
// @connect     self
// @version     9
// @namespace   ppixiv
// ==/UserScript==

(function() {

var resources = 
{
    "disabled.html": "<div class=ppixiv-disabled-ui>\r\n    <!-- The top-level template must contain only one node and we only create one\r\n         of these, so we just put this style in here. -->\r\n    <style>\r\n    .ppixiv-disabled-ui {\r\n        position: fixed;\r\n        top: 4px;\r\n        left: 4px;\r\n    }\r\n    .ppixiv-disabled-ui > a {\r\n        border: none;\r\n        display: block;\r\n        width: 46px;\r\n        height: 44px;\r\n        cursor: pointer;\r\n        background-color: transparent;\r\n        opacity: 0.7;\r\n        text-decoration: none;\r\n    }\r\n    .ppixiv-disabled-ui > a:hover {\r\n        opacity: 1;\r\n    }\r\n    </style>\r\n\r\n    <a href=\"#ppixiv\"></a>\r\n</div>\r\n", 
    "main.css": "* { box-sizing: border-box; }\r\nbody {\r\n    font-family: \"Helvetica Neue\", arial, sans-serif;\r\n}\r\na {\r\n    text-decoration: none;\r\n    /*color: #fff;*/\r\n    color: inherit;\r\n}\r\nbody.light a {\r\n    color: inherit;\r\n}\r\n.image-container {\r\n    width: 100%;\r\n    height: 100%;\r\n    user-select: none;\r\n    -moz-user-select: none;\r\n    cursor: pointer;\r\n}\r\n[hidden] {\r\n    display: none !important;\r\n}\r\n\r\ntextarea:focus, input:focus, a:focus {\r\n    outline: none;\r\n}\r\n\r\n.hide-cursor { cursor: none !important; }\r\n.hide-cursor * { cursor: inherit !important; }\r\n\r\n.main-container {\r\n    position: fixed;\r\n    top: 0px;\r\n    left: 0px;\r\n    width: 100%;\r\n    height: 100%;\r\n    overflow: hidden;\r\n}\r\n.progress-bar {\r\n    position: absolute;\r\n    pointer-events: none;\r\n    background-color: #00F;\r\n    bottom: 0px;\r\n    left: 0px;\r\n    width: 100%;\r\n    height: 2px;\r\n}\r\n@keyframes flash-progress-bar { to { opacity: 0; } }\r\n.refresh-icon {\r\n    cursor: pointer;\r\n}\r\n.progress-bar.hide {\r\n    animation: flash-progress-bar 500ms linear 1 forwards;\r\n}\r\n\r\n.loading-progress-bar .progress-bar {\r\n    z-index: 100;\r\n}\r\n\r\n/* .seek-bar is the outer seek bar area, which is what can be dragged. */\r\n.seek-bar {\r\n    position: absolute;\r\n    bottom: 0px;\r\n    left: 0px;\r\n    width: 100%;\r\n\r\n    box-sizing: content-box;\r\n    height: 12px;\r\n    padding-top: 25px;\r\n\r\n    cursor: pointer;\r\n}\r\n\r\n.seek-bar .seek-empty {\r\n    height: 100%;\r\n    background-color: rgba(0,0,0,0.25);\r\n}\r\n\r\n.seek-bar .seek-fill {\r\n    background-color: #F00;\r\n    height: 100%;\r\n}\r\n\r\n.seek-bar .seek-empty {\r\n    transition: transform .25s;\r\n    transform: translate(0, 12px);\r\n}\r\n\r\n.seek-bar.visible .seek-empty {\r\n    transform: translate(0, 6px);\r\n}\r\n.seek-bar.dragging .seek-empty {\r\n    transform: translate(0, 0);\r\n}\r\n\r\n.title-font {\r\n    font-weight: 700;\r\n    font-size: 20px;\r\n    font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,\r\n        Droid Sans, Helvetica Neue, Hiragino Kaku Gothic ProN, Meiryo, sans-serif;\r\n}\r\n\r\n.hover-message {\r\n    width: 100%;\r\n    position: absolute;\r\n    bottom: 0px;\r\n    display: flex;\r\n    justify-content: center;    \r\n    pointer-events: none;\r\n    opacity: 0;\r\n    transition: opacity .25s;\r\n}\r\n\r\n.hover-message.show {\r\n    opacity: 1;\r\n}\r\n\r\n.hover-message > .message {\r\n    background-color: #000;\r\n    color: #fff;\r\n    font-size: 1.4em;\r\n    padding: 6px 15px;\r\n    margin: 4px;\r\n    max-width: 600px;\r\n    text-align: center;\r\n    border-radius: 5px;\r\n    box-shadow: 0 0 10px 5px #aaa;\r\n}\r\n\r\nbody.light .hover-message > .message {\r\n    background-color: #eee;\r\n    color: #222;\r\n}\r\n\r\n.ui {\r\n    position: absolute;\r\n    top: 0px;\r\n    left: 0px;\r\n    min-width: 450px;\r\n    max-height: 500px;\r\n    width: 30%;\r\n    height: auto;\r\n    margin: .5em;\r\n    pointer-events: none;\r\n}\r\n\r\n.ui.disabled {\r\n    display: none;\r\n}\r\n\r\n/*\r\n * This is the box that triggers the UI to be displayed.  We use this rather than\r\n * ui-box for this so we can give it a fixed size.  That way, the UI box won't suddenly\r\n * appear when changing to another image because a longer description caused the box\r\n * to become bigger.\r\n *\r\n * This is a little tricky.  Hovering over either hover-box or the UI makes it visible.\r\n * When the UI is hidden, it's set to pointer-events: none, so it can't be hovered,\r\n * but once you hover over hover-box and cause the UI to be visible, pointer events\r\n * are reenabled so hovering over anywhere in the UI keeps it visible.  The UI is\r\n * over hover-box in the Z order, so we don't need to disable pointer events on hover-box\r\n * to prevent it from blocking the UI.\r\n *\r\n * We also disable pointer-events on the UI until it's visible, so it doesn't receive\r\n * clicks until it's visible.\r\n */\r\n.hover-box {\r\n    width: 400px;\r\n    height: 200px;\r\n    position: absolute;\r\n    top: 0;\r\n    left: 0;\r\n    pointer-events: auto; /* reenable pointer events that are disabled on .ui */\r\n}\r\n.ui-box {\r\n    background-color: #222;\r\n    border: solid 2px #000;\r\n    padding: 1em;\r\n    color: #EEE;\r\n    border-radius: 8px;\r\n    transition: transform .25s, opacity .25s;\r\n    opacity: 0;\r\n    transform: translate(-50px, 0);\r\n    pointer-events: none;\r\n}\r\nbody.light .ui-box {\r\n    background-color: #eee;\r\n    color: #222;\r\n    border-color: #ccc;\r\n}\r\n\r\n/* Debugging: */\r\nbody.force-ui .ui > .ui-box {\r\n    opacity: 1;\r\n    transform: translate(0, 0);\r\n    pointer-events: inherit;\r\n}\r\n\r\n/* Show the UI on hover when hide-ui isn\\'t set. */\r\nbody:not(.hide-ui) .ui-box:hover,\r\nbody:not(.hide-ui) .hover-box:hover + .ui-box {\r\n    opacity: 1;\r\n    transform: translate(0, 0);\r\n    pointer-events: auto;\r\n}\r\n\r\n\r\n.button-row {\r\n    display: flex;\r\n    flex-direction: row;\r\n    align-items: center;\r\n    height: 32px;\r\n    margin-top: 5px;\r\n    margin-bottom: 4px;\r\n}\r\n\r\n/* An icon in a button strip. */\r\n.icon-button {\r\n    display: block;\r\n    width: 32px;\r\n    height: auto;\r\n}\r\n\r\n.disable-ui-button > .icon-button {\r\n    /* The .pixiv-icon class on this element sets the background. */\r\n    padding: 3px; /* center the 26px icon */\r\n    margin: 0 4px;\r\n    cursor: pointer;\r\n}\r\n.disable-ui-button:hover > .icon-button {\r\n    fill: #0096FA;\r\n}\r\n.settings-menu-box > .icon-button {\r\n    padding: 4px; /* center the 24px icon */\r\n}\r\n.show-thumbnails-button {\r\n    cursor: pointer;\r\n}\r\n\r\n@keyframes spin { to { transform: rotate(360deg); } }\r\n.refresh-icon {\r\n    animation: spin 1000ms linear infinite;\r\n    animation-play-state: paused;\r\n}\r\n\r\n/* The icon SVG is placed in this. */\r\n.refresh-icon:after {\r\n    display: block;\r\n    width: 20px;\r\n    height: 20px;\r\n}\r\n.refresh-icon.spin {\r\n    animation-play-state: running;\r\n}\r\n\r\n.settings-menu-box svg {\r\n    margin: 0 .5em;\r\n}\r\n\r\n/* Toggle boxes for the settings menu: */\r\n.thumbnail-container.big-thumbnails .toggle-big-thumbnails > .off { display: none; }\r\n.thumbnail-container:not(.big-thumbnails) .toggle-big-thumbnails > .on { display: none; }\r\n\r\nbody.light .toggle-light-mode > .off { display: none; }\r\nbody:not(.light) .toggle-light-mode > .on { display: none; }\r\n\r\nbody:not(.disable-thumbnail-zooming) .toggle-thumbnail-zooming > .off { display: none; }\r\nbody.disable-thumbnail-zooming .toggle-thumbnail-zooming > .on { display: none; }\r\n\r\nbody:not(.disable-thumbnail-panning) .toggle-thumbnail-panning > .off { display: none; }\r\nbody.disable-thumbnail-panning .toggle-thumbnail-panning > .on { display: none; }\r\n\r\n.download-button {\r\n    cursor: pointer;\r\n}\r\n.download-button > svg {\r\n    margin: 0 5px;\r\n    width: 20px;\r\n    height: 20px;\r\n}\r\n.bookmark-line > * {\r\n    vertical-align: middle;\r\n    display: inline-block;\r\n}\r\n.popup.avatar-popup:hover:after {\r\n    left: auto;\r\n    bottom: auto;\r\n    top: 60px;\r\n    right: -10px;\r\n}\r\n.follow-container .avatar {\r\n    border-radius: 5px;\r\n    object-fit: cover;\r\n}\r\n.follow-container:not(.big) .avatar {\r\n    width: 50px;\r\n    height: 50px;\r\n}\r\n.follow-container.big .avatar {\r\n    width: 170px;\r\n    height: 170px;\r\n}\r\n.follow-popup {\r\n    margin-top: 10px;\r\n    right: 0px;\r\n}\r\n.follow-container .hover-area {\r\n    top: -12px;\r\n}\r\n.follow-container .avatar-link {\r\n    display: block;\r\n}\r\n.follow-popup .folder {\r\n    display: block;\r\n}\r\n\r\n.follow-container.followed .follow-popup .not-following { display: none; }\r\n.follow-container:not(.followed) .follow-popup .following { display: none; }\r\n.follow-container.followed .avatar {\r\n    box-shadow: 0 0 15px 10px #0aa;\r\n}\r\n\r\n.title-block {\r\n    display: inline-block;\r\n    padding: 0 10px;\r\n    background-color: #444;\r\n    margin-right: 1em;\r\n    border-radius: 8px 0;\r\n}\r\n.light .title-block {\r\n    background-color: #888;\r\n    color: #fff;\r\n}\r\n.title-block.popup:hover:after {\r\n    top: 40px;\r\n    bottom: auto;\r\n}\r\n.author {\r\n    vertical-align: top;\r\n}\r\n/* When .dot is set, show images with nearest neighbor filtering. */\r\nbody.dot img.filtering,\r\nbody.dot canvas.filtering {\r\n    image-rendering: -moz-crisp-edges;\r\n    image-rendering: crisp-edges;\r\n    image-rendering: pixelated;\r\n}\r\n/* When not bookmarked, highlight on hover and show the private icon on control-hover. */\r\n/* When bookmarked, show the bookmark state. */\r\n.bookmark-button { cursor: pointer; }\r\n.bookmark-button .bookmark-popup { cursor: initial; } /* stop cursor: pointer above from propagating all the way down */\r\nbody:not(.bookmarked) .bookmark-button:hover              svg.heart,\r\nbody.bookmarked       .bookmark-button.bookmarked-public  svg.heart { fill: #f00; }\r\n                      .bookmark-button.bookmarked-private svg.heart { fill: #800; stroke: #fff; }\r\nbody.bookmarked .bookmark-button input { display: none; }\r\n.bookmark-button > span.popup {\r\n    margin: 0 3px;\r\n}\r\n.like-button {\r\n    cursor: pointer;\r\n}\r\n.like-button > .icon-button {\r\n    margin: 0 4px;\r\n}\r\n.similar-illusts-button:hover > .icon-button {\r\n    fill: #FF0 !important; /* override grey-icon hover color */\r\n}\r\n.similar-illusts-button > .icon-button {\r\n    margin-top: -3px;\r\n}\r\n\r\n.like-button.liked > .icon-button,\r\n.like-button:not(.liked) > .icon-button:hover {\r\n    fill: #0F0 !important; /* override grey-icon hover color */\r\n}\r\n.post-info {\r\n}\r\n.post-info > * {\r\n    display: inline-block;\r\n    background-color: #111;\r\n    color: #eee;\r\n    padding: 2px 10px;\r\n\r\n    /* Use a smaller, heavier font to distinguish these from tags. */\r\n    font-size: .8em;\r\n    font-weight: bold;\r\n}\r\n.description {\r\n    border: solid 1px #000;\r\n    padding: .35em;\r\n    background-color: #555;\r\n    max-height: 10em;\r\n    overflow-y: auto;\r\n}\r\n.light .description {\r\n    background-color: #ccc;\r\n    border: none;\r\n}\r\n/* Override obnoxious colors in descriptions.  Why would you allow this? */\r\n.description * {\r\n    color: #eee !important;\r\n}\r\nbody.light .description * {\r\n    color: #222 !important;\r\n}\r\n\r\n.popup {\r\n    position: relative;\r\n}\r\n.popup:hover:after {\r\n    pointer-events: none;\r\n    background: #111;\r\n    border-radius: .5em;\r\n    left: 0em;\r\n    top: -2.0em;\r\n    color: #fff;\r\n    content: attr(data-popup);\r\n    display: block;\r\n    padding: .3em 1em;\r\n    position: absolute;\r\n    text-shadow: 0 1px 0 #000;\r\n    white-space: nowrap;\r\n    z-index: 98;\r\n}\r\n\r\nbody:not(.premium) .premium-only { display: none; }\r\n.popup-menu-box {\r\n    position: absolute;\r\n    visibility: hidden;\r\n    min-width: 10em;\r\n    background-color: #000;\r\n    border: 1px solid #444;\r\n    padding: .25em .5em;\r\n    z-index: 1;\r\n}\r\nbody.light .popup-menu-box {\r\n    background-color: #fff;\r\n    border-color: #ddd;\r\n}\r\n.bookmark-popup {\r\n    top: 30px;\r\n    left: 0px;\r\n}\r\n.popup-visible .popup-menu-box {\r\n    visibility: inherit;\r\n}\r\n\r\n/* This is an invisible block underneath the hover zone to keep the hover UI visible. */\r\n.hover-area {\r\n    position: absolute;\r\n    top: -50%;\r\n    left: -33%;\r\n    width: 150%;\r\n    height: 200%;\r\n    z-index: -1;\r\n}\r\n/* This one is under the bookmark popup.  Extend over the bottom, so the list doesn\\'t disappear\r\n * when deleting a recent bookmark at the bottom of the list, but don\\'t extend over the top, so\r\n * we don\\'t block the mouse hovering over other things.\r\n *\r\n * Note that the positioning of this is important: we want to fully close the gap between the\r\n * popup and the bottom that opened it, but we don't want to overlap the button and block it. */\r\n.bookmark-popup > .hover-area,\r\n.navigation-menu-box .hover-area,\r\n.settings-menu-box .hover-area\r\n{\r\n    top: -2px;\r\n    height: 125%;\r\n}\r\n\r\n.bookmark-popup input,\r\n.follow-popup input{\r\n    margin: .25em;\r\n    padding: .25em;\r\n}\r\n.popup-menu-box .button {\r\n    padding: .25em;\r\n    cursor: pointer;\r\n}\r\n\r\nbody.bookmarked .bookmark-popup .not-bookmarked { display: none; }\r\nbody:not(.bookmarked) .bookmark-popup .bookmarked { display: none; }\r\n.popup-menu-box .button:hover {\r\n    background-color: #444;\r\n    width: 100%;\r\n}\r\nbody.light .popup-menu-box .button:hover {\r\n    background-color: #ccc;\r\n}\r\n\r\n.bookmark-tag-selector {\r\n    width: 100%;\r\n    max-height: 200px;\r\n    overflow-y: auto;\r\n}\r\n.bookmark-tag-entry {\r\n    cursor: pointer;\r\n    padding: 2px 8px;\r\n    display: flex;\r\n}\r\n.bookmark-tag-entry > .tag-name {\r\n    flex: 1;\r\n}\r\n.bookmark-tag-entry.enabled { background-color: #008; }\r\n.bookmark-tag-entry:hover { background-color: #444; }\r\n.bookmark-tag-entry:hover.enabled { background-color: #44a; }\r\nbody.light .bookmark-tag-entry.enabled { background-color: #00c; color: #fff; }\r\nbody.light .bookmark-tag-entry:hover { background-color: #ccc; }\r\nbody.light .bookmark-tag-entry:hover.enabled { background-color: #44a; }\r\n\r\n.thumbnail-container {\r\n    position: absolute;\r\n    width: 100%;\r\n    height: 100%;\r\n    top: 0;\r\n    left: 0;\r\n    /* Always show the vertical scrollbar, so we don't relayout as images load. */\r\n    overflow-y: scroll;\r\n    background-color: #000;\r\n    color: #fff;\r\n}\r\n\r\n.thumbnail-container .thumbnail-ui {\r\n    /* This places the thumbnail UI at the top, so the thumbnails sit below it when\r\n     * scrolled all the way up, and scroll underneath it. */\r\n    position: sticky;\r\n    top: 0;\r\n    width: 100%;\r\n    display: flex;\r\n    flex-direction: row;\r\n    align-items: center;\r\n    padding-top: 1em;\r\n    margin-bottom: .5em;\r\n    z-index: 1;\r\n}\r\n\r\n.thumbnail-container .thumbnail-ui-box {\r\n    width: 50%;\r\n    /* Make sure this doesn't get too narrow, or it'll overlap too much of the thumbnail area. */\r\n    min-width: 800px;\r\n    background-color: #444;\r\n\r\n    box-shadow: 0 0 15px 10px #000;\r\n    border-radius: 4px;\r\n\r\n    padding: 10px;\r\n}\r\n\r\nbody.light .thumbnail-container .thumbnail-ui-box {\r\n    background-color: #ddd;\r\n    color: #444;\r\n    box-shadow: 0 0 15px 10px #fff;\r\n}\r\n\r\n\r\n.thumbnail-container .thumbnail-ui-box .displaying {\r\n    padding-bottom: 4px;\r\n}\r\n\r\n/* .thumbnails is the actual thumbnail list. */\r\n.thumbnail-container .thumbnails {\r\n    user-select: none;\r\n    -moz-user-select: none;\r\n    padding: 0;\r\n    text-align: center;\r\n}\r\n\r\n.thumbnail-container ul {\r\n    margin: 0;\r\n    max-width: 1200px;\r\n    margin: 0 auto; /* center */\r\n}\r\n\r\n.thumbnail-container li.thumbnail-box {\r\n    display: inline-block;\r\n    padding: 1em;\r\n}\r\n\r\n.thumbnail-container li.thumbnail-box a.thumbnail-link {\r\n    display: block;\r\n\r\n    /* Note that the actual images we get are up to 240x240.  Making this smaller can look\r\n     * cramped, and also can significantly increase the number of pages we'll load at once\r\n     * by causing lots of thumbnails to be on screen at once. */\r\n    width: 200px;\r\n    height: 200px;\r\n    border-radius: 4px;\r\n    overflow: hidden;\r\n    position: relative;\r\n    text-decoration: none;\r\n    color: #fff;\r\n}\r\n\r\n.thumbnail-container.big-thumbnails li.thumbnail-box a.thumbnail-link {\r\n    width: 300px;\r\n    height: 300px;\r\n}\r\n\r\n.page-count-box {\r\n    pointer-events: none;\r\n    position: absolute;\r\n    right: 2px;\r\n    bottom: 2px;\r\n    padding: 4px 8px;\r\n    background-color: rgba(0,0,0,.6);\r\n    border-radius: 6px;\r\n}\r\n\r\n.page-count-box .page-icon {\r\n    width: 16px;\r\n    height: 16px;\r\n    display: inline-block;\r\n    vertical-align: middle;\r\n}\r\n\r\n.page-count-box {\r\n    transition: opacity .5s;\r\n}\r\n.thumbnail-inner:hover .page-count-box {\r\n    opacity: 0.5;\r\n}\r\n\r\n.page-count-box .page-count {\r\n    vertical-align: middle;\r\n    margin-left: -4px;\r\n}\r\n\r\n.thumbnail-container li.thumbnail-box .ugoira-icon {\r\n    pointer-events: none;\r\n    width: 32px;\r\n    height: 32px;\r\n    right: 0px;\r\n    bottom: 0px;\r\n    color: #fff;\r\n    position: absolute;\r\n    transition: opacity .5s;\r\n}\r\n\r\n.thumbnail-inner:hover .ugoira-icon {\r\n    opacity: 0.5;\r\n}\r\n\r\n.thumbnail-container li.thumbnail-box[data-pending] a {\r\n    opacity: 0.5;\r\n    background-color: #444;\r\n}\r\n\r\n/* Hide the img while it's pending so we don't show a broken image icon. */\r\n.thumbnail-container li.thumbnail-box[data-pending] a img.thumb {\r\n    display: none;\r\n}\r\n\r\n.thumbnail-container .thumb {\r\n    object-fit: cover;\r\n\r\n    /* Show the top-center of the thunbnail.  This generally makes more sense\r\n     * than cropping the center. */\r\n    object-position: 50% 0%;    \r\n    width: 100%;\r\n    height: 100%;\r\n\r\n    transition: transform .5s;\r\n\r\n    /* Zooming in on hover and zooming out both look interesting.  I'm not sure\r\n     * which is better. */\r\n    transform: scale(1.25, 1.25);\r\n}\r\n\r\nbody.disable-thumbnail-zooming .thumbnail-box .thumb {\r\n    transform: scale(1, 1);\r\n}\r\n\r\nbody:not(.disable-thumbnail-zooming) .thumbnail-box .thumb:hover {\r\n    transform: scale(1, 1);\r\n}\r\n\r\n.thumbnail-box.vertical-panning .thumb,\r\n.thumbnail-box.horizontal-panning .thumb\r\n{\r\n    animation-duration: 4s;\r\n    animation-timing-function: ease-in-out;\r\n    animation-iteration-count: infinite;\r\n}\r\n\r\n.thumbnail-box .thumb:not(:hover) {\r\n    animation-play-state: paused;\r\n}\r\n\r\nbody:not(.disable-thumbnail-panning) .thumbnail-box.horizontal-panning .thumb {\r\n    animation-name: pan-thumbnail-horizontally;\r\n    object-position: left top;\r\n\r\n    /* The full animation is 4 seconds, and we want to start 20% in, at the halfway\r\n     * point of the first left-right pan, where the pan is exactly in the center where\r\n     * we are before any animation.  This is different from vertical panning, since it\r\n     * pans from the top, which is already where we start (top center). */\r\n    animation-delay: -.8s;\r\n\r\n}\r\nbody:not(.disable-thumbnail-panning) .thumbnail-box.vertical-panning .thumb {\r\n    animation-name: pan-thumbnail-vertically;\r\n}\r\n\r\n@keyframes pan-thumbnail-horizontally {\r\n    /* This starts in the middle, pans left, pauses, pans right, pauses, returns to the middle, then pauses again. */\r\n    0%   { object-position: left top; } /* left */\r\n    40%  { object-position: right top; } /* pan right */\r\n    50%  { object-position: right top; } /* pause */\r\n    90%  { object-position: left top; } /* pan left */\r\n    100%  { object-position: left top; } /* pause */\r\n}\r\n\r\n@keyframes pan-thumbnail-vertically {\r\n    /* This starts at the top, pans down, pauses, pans back up, then pauses again. */\r\n    0%   { object-position: 50% 0%; }\r\n    40%  { object-position: 50% 100%; }\r\n    50%  { object-position: 50% 100%; }\r\n    90%  { object-position: 50% 0%; }\r\n    100% { object-position: 50% 0%; }\r\n}\r\n\r\n.thumbnail-container .thumbnail-box:not(.muted) .muted {\r\n    display: none;\r\n}\r\n.thumbnail-container .thumbnail-box .muted {\r\n    pointer-events: none;\r\n    left: 0;\r\n    top: 50%;\r\n    width: 100%;\r\n    height: 32px;\r\n    color: #000;\r\n    position: absolute;\r\n    text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff;\r\n    font-size: 22px;\r\n}\r\n\r\n/* Zoom muted images in a little, and zoom them out on hover, which is the opposite\r\n * of other images.  This also helps hide the black bleed around the edge caused by\r\n * the blur. */\r\n.thumbnail-container .thumbnail-box.muted .thumb {\r\n    filter: blur(10px);\r\n    transform: scale(1.25, 1.25);\r\n}\r\nbody:not(.disable-thumbnail-zooming) .thumbnail-container .thumbnail-box.muted .thumb:hover {\r\n    transform: scale(1, 1);\r\n}\r\n\r\n/* Hide the fake thumbnail used to detect when we've scrolled to the bottom.\r\n * Note that we need to hide something inside the <li>, not the whole entry,\r\n * or offsetTop will be 0, which breaks get_visible_thumbnails. */\r\n.thumbnail-container .next-page-placeholder > .thumbnail-inner {\r\n    display: none !important;\r\n}\r\n\r\n.thumbnail-container .dot img.thumb {\r\n    /* This doesn't work as well on thumbnails. */\r\n    /*\r\n    image-rendering: -moz-crisp-edges;\r\n    image-rendering: crisp-edges;\r\n    image-rendering: pixelated;\r\n    */\r\n}\r\n\r\n@keyframes flash-thumbnail {\r\n    0% {\r\n        box-shadow: 0 0 15px 10px #0d0;\r\n    }\r\n}\r\n\r\n.thumbnail-container .flash a {\r\n    animation-name: flash-thumbnail;\r\n    animation-duration: 300ms;\r\n    animation-timing-function: ease-out;\r\n    animation-iteration-count: 1;\r\n}    \r\n\r\nbody:not(.premium) .bookmark-tag-selection { display: none; }\r\n\r\n.box-link {\r\n    display: inline-block;\r\n    cursor: pointer;\r\n    text-decoration: none;\r\n    padding: .25em .5em;\r\n    margin: .25em .25em;\r\n    color: #fff;\r\n    background-color: #000;\r\n}\r\nbody.light .box-link {\r\n    background-color: #fff;\r\n    color: #222;\r\n}\r\n\r\n.box-link:hover {\r\n    background-color: #222;\r\n}\r\n\r\nbody.light .box-link:hover {\r\n    background-color: #eee;\r\n}\r\n\r\n.box-link.selected {\r\n    background-color: #008;\r\n}\r\nbody.light .box-link.selected {\r\n    background-color: #ffc;\r\n}\r\n\r\n.thumbnail-container .following-tag {\r\n    text-decoration: none;\r\n}\r\n\r\n.thumbnail-container .search-options-row {\r\n    display: flex;\r\n    flex-direction: row;\r\n}\r\n\r\n.thumbnail-container .search-options-row .hover-area {\r\n    top: -10px;\r\n    height: 150%;\r\n}\r\n.thumbnail-container .option-list {\r\n    display: flex;\r\n    flex-direction: column;\r\n}\r\n.search-options-row > div.active > .box-link {\r\n    background-color: #004;\r\n}\r\n.light .search-options-row > div.active > .box-link {\r\n    background-color: #ffc;\r\n}\r\n.search-box {\r\n    white-space: nowrap;\r\n    margin-bottom: 4px;\r\n    position: relative; /* to position the search dropdown */\r\n}\r\n\r\n/* The block around the input box and submit button.  A history dropdown widget will\r\n * be placed in here. */\r\n.tag-search-box {\r\n    display: inline-block;\r\n    position: relative;\r\n}\r\n\r\ninput.search-tags {\r\n    font-size: 1.2em;\r\n    padding: 6px 10px;\r\n    padding-right: 30px; /* extra space for the submit button */\r\n    vertical-align: middle;\r\n}\r\n\r\n.thumbnail-container .search-submit-button {\r\n    display: inline-block;\r\n    margin-left: -30px; /* overlap the input */\r\n    vertical-align: middle;\r\n    cursor: pointer;\r\n}\r\n\r\n.thumbnail-ui-box .avatar-container {\r\n    float: right;\r\n    position: relative;\r\n    margin-left: 25px;\r\n    margin-bottom: 10px;\r\n}\r\n\r\n.image-for-suggestions {\r\n    float: right;\r\n    margin-left: 25px;\r\n    margin-bottom: 10px;\r\n}\r\n\r\n.grey-icon {\r\n    fill: #888;\r\n}\r\n.light .grey-icon {\r\n    fill: #666;\r\n}\r\n:hover > .grey-icon {\r\n    fill: #eee;\r\n}\r\n.light :hover > .grey-icon {\r\n    fill: #222;\r\n}\r\n/* If a grey-icon is directly inside a visible popup menu, eg. the navigation icon: */\r\n.popup-visible > .grey-icon {\r\n    fill: #eee;\r\n}\r\n.light .popup-visible > .grey-icon {\r\n    fill: #222;\r\n}\r\n\r\n.mute-display .muted-image {\r\n    position: absolute;\r\n    top: 0;\r\n    left: 0;\r\n    width: 100%;\r\n    height: 100%;\r\n    object-fit: cover;\r\n    filter: blur(20px);\r\n    opacity: .75;\r\n}\r\n\r\n.mute-display .muted-text {\r\n    position: absolute;\r\n    width: 100%;\r\n    top: 50%;\r\n    left: 0;\r\n    text-align: center;\r\n    font-size: 30px;\r\n    color: #000;\r\n    text-shadow: 0px 1px 1px #fff, 0px -1px 1px #fff, 1px 0px 1px #fff, -1px 0px 1px #fff;\r\n}\r\n\r\n/* Tag lists are usually inline.  Make the tag filter a vertical list. */\r\n.member-tags-box .post-tag-list,\r\n.search-tags-box .related-tag-list {\r\n    max-height: 300px;\r\n    display: block;\r\n    overflow-x: hidden;\r\n    overflow-y: auto;\r\n    white-space: nowrap;\r\n}\r\n.member-tags-box .post-tag-list .following-tag,\r\n.search-tags-box .related-tag-list .tag {\r\n    display: block;\r\n}\r\n\r\n.member-tags-box .post-tag-list .following-tag:hover:after,\r\n.search-tags-box .related-tag-list .tag:hover:after {\r\n    left: auto;\r\n    right: 0px;\r\n}\r\n\r\n.input-dropdown {\r\n    width: 100%;\r\n    margin: 1px;\r\n    max-height: 400px;\r\n    overflow-x: hidden;\r\n    overflow-y: auto;\r\n    position: absolute;\r\n    background-color: #fff;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list {\r\n    display: flex;\r\n    flex-direction: column;\r\n}\r\n.input-dropdown > .input-dropdown-list > .entry {\r\n    display: flex;\r\n    flex-direction: row;\r\n    color: #000;\r\n    align-items: center;\r\n}\r\n.input-dropdown > .input-dropdown-list > .entry.selected {\r\n    background-color: #ffa;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list > .entry:hover {\r\n    background-color: #ddd;\r\n}\r\n\r\n/* Hide the button to remove history entries from non-history entries. */\r\n.input-dropdown > .input-dropdown-list > .entry:not(.history) .remove-history-entry {\r\n    display: none;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list > .entry > A.tag {\r\n    color: #000;\r\n    flex: 1;\r\n    padding: 4px;\r\n    padding-left: 7px;\r\n}\r\n\r\n.input-dropdown > .input-dropdown-list .remove-history-entry {\r\n    padding: 0 8px 0px 5px;\r\n}\r\n.input-dropdown > .input-dropdown-list .remove-history-entry:hover {\r\n    color: #f33;\r\n}\r\n\r\n.manga-thumbnail-container\r\n{\r\n    position: absolute;\r\n    bottom: 0;\r\n    left: 0;\r\n    width: 100%;\r\n    height: 240px;\r\n    max-height: 30%;\r\n    user-select: none;\r\n    -moz-user-select: none;\r\n}\r\n\r\nbody.hide-ui .manga-thumbnail-container\r\n{\r\n    display: none;\r\n}\r\n\r\n/* The .strip container is the overall strip.  This is a flexbox that puts the nav\r\n * arrows on the outside, and the thumb strip stretching in the middle.  The thumb\r\n * strip itself is also a flexbox, for the actual thumbs. */\r\n.manga-thumbnail-container > .strip\r\n{\r\n    background-color: #444;\r\n    height: 100%;\r\n    display: flex;\r\n    flex-direction: row;\r\n\r\n    opacity: 0;\r\n    transition: transform .15s, opacity .15s;\r\n    transform: translate(0, 25px);\r\n}\r\nbody.light .manga-thumbnail-container > .strip\r\n{\r\n    background-color: #ddd;\r\n}\r\n\r\n.manga-thumbnail-container.visible > .strip\r\n{\r\n    opacity: 1;\r\n    transform: translate(0, 0);\r\n}\r\n\r\n.manga-thumbnail-container > .strip > .manga-thumbnails {\r\n    flex: 1;\r\n\r\n    display: flex;\r\n    flex-direction: row;\r\n    overflow: hidden;\r\n    justify-content: left;\r\n    scroll-behavior: smooth;\r\n    height: 100%;\r\n    padding: 5px 0;\r\n}\r\n\r\n.manga-thumbnail-container .manga-thumbnail-box\r\n{\r\n    cursor: pointer;\r\n    height: 100%;\r\n    margin: 0 5px;\r\n\r\n    /* The first entry has the cursor inside it.  Set these to relative, so the\r\n     * cursor position is relative to it. */\r\n    position: relative;\r\n}\r\n\r\n.manga-thumbnail-container .manga-thumbnail-box img.manga-thumb\r\n{\r\n    height: 100%;\r\n    width: auto;\r\n    border-radius: 3px;\r\n\r\n    /* This will limit the width to 300px, cropping if needed.  This prevents\r\n     * very wide aspect ratio images from breaking the layout.  Only a fixed\r\n     * size will work here, percentage values won't work. */\r\n    max-width: 400px;\r\n    object-fit: cover;\r\n}\r\n\r\n.manga-thumbnail-arrow\r\n{\r\n    height: 100%;\r\n    width: 30px;\r\n    margin: 0 6px;\r\n}\r\n\r\n.manga-thumbnail-arrow > svg\r\n{\r\n    fill: #888;\r\n}\r\n.manga-thumbnail-arrow:hover > svg\r\n{\r\n    fill: #ff0;\r\n}\r\nbody.light .manga-thumbnail-arrow:hover > svg\r\n{\r\n    stroke: #aa0;\r\n}\r\n\r\n.manga-thumbnail-arrow > svg\r\n{\r\n    display: block;\r\n    height: 100%;\r\n    width: 100%;\r\n    padding: 4px;\r\n}\r\n\r\n.thumb-list-cursor\r\n{\r\n    position: absolute;\r\n    left: 0;\r\n    bottom: -6px;\r\n    width: 40px;\r\n    height: 4px;\r\n    background-color: #FFF;\r\n    border-radius: 2px;\r\n}\r\n\r\nbody.light .thumb-list-cursor\r\n{\r\n    background-color: #666;\r\n}\r\n\r\n", 
    "main.html": "<div class=\"main-container noise-background\">\n    <div class=image-container></div>\n\n    <div class=ugoira-seek-bar></div>\n    <div class=loading-progress-bar></div>\n\n    <!-- This covers the progress bar. -->\n    <div class=manga-thumbnail-container>\n        <div class=strip>\n            <div class=manga-thumbnail-arrow data-direction=left>\n                <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"100\" viewBox=\"0 0 20 100\">\n                    <path d=\"M 0 50 L 20 0 L 20 100 L 0 50\" />\n                </svg>\n            </div>\n\n            <div class=manga-thumbnails></div>\n\n            <div class=manga-thumbnail-arrow data-direction=right>\n                <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"100\" viewBox=\"0 0 20 100\">\n                    <path d=\"M 20 50 L 0 0 L 0 100 L 20 50\" />\n                </svg>\n            </div>\n        </div>\n    </div>\n\n    <div class=hover-message>\n        <div class=message></div>\n    </div>\n\n    <div class=ui>\n        <div class=hover-box></div>\n        <div class=ui-box>\n            <!-- The avatar icon in the top-right.  This is absolutely positioned, since we don't\n                 want this to push the rest of the UI down. -->\n            <div class=\"avatar-popup\" style=\"position: absolute; top: 1em; right: 1em;\"></div>\n\n            <!-- The title and author.  The margin-right here is to prevent this from\n                 overlapping the absolutely-positioned avatar icon above. -->\n            <div style=\"display: flex; flex-direction: row; margin-right: 4em;\">\n                <div>\n                    <span class=\"title-block\">\n                        <!-- Put the title and author in separate inline-blocks, to encourage\n                             the browser to wrap between them if possible, putting the author\n                             on its own line if they won\\'t both fit, but still allowing the\n                             title to wrap if it\\'s too long by itself. -->\n                        <span style=\"display: inline-block;\" class=\"title-font\">\n                            <a class=\"title\"></a>\n                        </span>\n                        <span style=\"display: inline-block;\" class=\"author-block title-font\">\n                            <span style=\"font-size: 12px;\">by</span>\n                            <a class=\"author\"></a>\n                        </span>\n                        <a class=edit-post href=#>Edit post</a>\n                    </span>\n                </div>\n            </div>\n\n            <div class=button-row>\n                <a class=\"disable-ui-button popup\" data-popup=\"Return to Pixiv\" href=\"#no-ppixiv\">\n                    <svg class=\"pixiv-icon icon-button grey-icon\" xmlns=\"http://www.w3.org/2000/svg\" width=\"50\" height=\"50\" viewBox=\"-2 6 50 50\">\n                        <path d=\"M35.93 16.864a14.682 14.682 0 0 1 5.087 11.185c.013 4.453-2.118 8.348-5.424 10.957-3.306 2.624-7.75 4.076-12.636 4.076-5.56 0-10.718-2.023-10.718-2.023v6.527c.953.279 2.517.876 1.524 1.869H6.24c-.983-.983.46-1.562 1.55-1.87V19.918c-2.53 1.943-3.827 3.627-4.49 4.877.768 2.447-.681 2.326-.681 2.326L0 22.965s9.294-10.537 22.957-10.537c5.242 0 9.753 1.623 12.973 4.436zm-3.986 19.958c2.286-2.275 3.57-5.241 3.584-8.843-.011-3.696-1.213-6.876-3.413-9.27-2.205-2.379-5.465-3.947-9.617-3.95-3.418-.007-7.65 1.135-10.259 2.988v20.758c2.378 1.17 5.98 1.999 10.259 1.996 3.835.003 7.16-1.424 9.446-3.68zM47.67 12.428c.68 0 1.232.551\"/>\n                    </svg>                    \n                </a>\n\n                <div class=\"show-thumbnails-button popup\" data-popup=\"Show all\">\n                    <svg class=\"grey-icon icon-button\" xmlns=\"http://www.w3.org/2000/svg\" width=\"38\" height=\"32\" viewBox=\"0 -1 32 32\" style=\"\">\n                        <path d=\"M 4 4 h 10 v 10 h -10 v -10\"/>\n                        <path d=\"M 18 4 h 10 v 10 h -10 v -10\"/>\n                        <path d=\"M 4 18 h 10 v 10 h -10 v -10\"/>\n                        <path d=\"M 18 18 h 10 v 10 h -10 v -10\"/>\n                    </svg>\n                </div>\n\n                <div class=\"download-button popup\">\n                    <svg class=\"grey-icon icon-button\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 537.794 537.795\">\n                        <g>\n                            <path d=\"M463.091,466.114H74.854c-11.857,0-21.497,9.716-21.497,21.497v28.688c0,11.857,9.716,21.496,21.497,21.496h388.084\n                                    c11.857,0,21.496-9.716,21.496-21.496v-28.688C484.665,475.677,474.949,466.114,463.091,466.114z\"/>\n                            <path d=\"M253.94,427.635c4.208,4.208,9.716,6.35,15.147,6.35c5.508,0,11.016-2.142,15.147-6.35l147.033-147.033\n                                    c8.339-8.338,8.339-21.955,0-30.447l-20.349-20.349c-8.339-8.339-21.956-8.339-30.447,0l-75.582,75.659V21.497\n                                    C304.889,9.639,295.173,0,283.393,0h-28.688c-11.857,0-21.497,9.562-21.497,21.497v284.044l-75.658-75.659\n                                    c-8.339-8.338-22.032-8.338-30.447,0l-20.349,20.349c-8.338,8.338-8.338,22.032,0,30.447L253.94,427.635z\"/>\n                        </g>\n                    </svg>\n                </div>\n\n                <div class=bookmark-button style=\"display: flex; flex-direction: row; align-items: center;\">\n                    <span class=popup>\n                        <svg class=\"heart grey-icon icon-button\" viewBox=\"0 0 32 32\" width=32 height=32>\n                            <path d=\"M21,5.5 C24.8659932,5.5 28,8.63400675 28,12.5 C28,18.2694439 24.2975093,23.1517313 17.2206059,27.1100183 C16.4622493,27.5342993 15.5379984,27.5343235 14.779626,27.110148 C7.70250208,23.1517462 4,18.2694529 4,12.5 C4,8.63400691 7.13400681,5.5 11,5.5 C12.829814,5.5 14.6210123,6.4144028 16,7.8282366 C17.3789877,6.4144028 19.170186,5.5 21,5.5 Z\" />\n                        </svg>\n                        <div class=\"popup-menu-box bookmark-popup\">\n                            <div class=hover-area></div>\n                            <div class=bookmarked>\n                                <div class=\"button unbookmark-button\">\n                                    Remove&nbsp;Bookmark\n                                </div>\n                            </div>\n                            <div class=not-bookmarked>\n                                <div class=\"button bookmark-button public\">\n                                    Bookmark\n                                </div>\n                                <div class=\"button bookmark-button private\">\n                                    Bookmark&nbsp;Privately\n                                </div>\n                                <div class=premium-only>\n                                    <input class=bookmark-tag-list>\n                                    <div style=\"display: flex; align-items: center;\">\n                                        <div style=\"flex: 1;\">Bookmark tags:</div>\n                                        <div class=\"refresh-bookmark-tags refresh-icon\"></div>\n                                    </div>\n                                    <div class=bookmark-tag-selector></div>\n                                </div>\n                            </div>\n                        </div>\n                    </span>\n                </div>\n               \n                <div class=\"like-button popup\">\n                    <svg class=\"grey-icon icon-button\" viewBox=\"0 0 12 12\" width=28 height=28>\n                        <path d=\"M2,6 C0.8954305,6 0,5.1045695 0,4 C0,2.8954305 0.8954305,2 2,2 C3.1045695,2 4,2.8954305 4,4 C4,5.1045695 3.1045695,6 2,6 Z M10,6 C8.8954305,6 8,5.1045695 8,4 C8,2.8954305 8.8954305,2 10,2 C11.1045695,2 12,2.8954305 12,4 C12,5.1045695 11.1045695,6 10,6 Z M2.1109127,8.8890873 C1.72038841,8.498563 1.72038841,7.86539803 2.1109127,7.47487373 C2.501437,7.08434944 3.13460197,7.08434944 3.52512627,7.47487373 C4.89196129,8.84170876 7.10803871,8.84170876 8.47487373,7.47487373 C8.86539803,7.08434944 9.498563,7.08434944 9.8890873,7.47487373 C10.2796116,7.86539803 10.2796116,8.498563 9.8890873,8.8890873 C7.74120369,11.0369709 4.25879631,11.0369709 2.1109127,8.8890873 Z\" />\n                    </svg>\n                </div>\n\n                <a class=\"similar-illusts-button popup\" data-popup=\"Similar illustrations\" href=#>\n                    <svg class=\"grey-icon icon-button\" xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" width=\"32\" height=\"32\" viewBox=\"10 5 80 60\">\n                        <path d=\"M52.084,56.25H43.75c-1.151,0-2.083-0.932-2.083-2.083s0.932-2.084,2.083-2.084h8.334  c1.151,0,2.083,0.933,2.083,2.084S53.235,56.25,52.084,56.25z\"></path>\n                        <path d=\"M47.917,12.5c-1.151,0-2.083-0.932-2.083-2.083V2.083C45.834,0.932,46.766,0,47.917,0S50,0.932,50,2.083  v8.333C50,11.568,49.068,12.5,47.917,12.5z\"></path>\n                        <path d=\"M29.167,31.25h-8.333c-1.151,0-2.084-0.932-2.084-2.083s0.933-2.084,2.084-2.084h8.333  c1.151,0,2.083,0.933,2.083,2.084S30.318,31.25,29.167,31.25z\"></path>\n                        <path d=\"M34.375,17.708c-0.532,0-1.065-0.203-1.473-0.61l-5.895-5.892c-0.813-0.814-0.813-2.132,0-2.946  c0.813-0.814,2.132-0.814,2.946,0l5.895,5.892c0.813,0.814,0.813,2.132,0,2.946C35.441,17.505,34.908,17.708,34.375,17.708z\"></path>\n                        <path d=\"M61.459,17.708c-0.533,0-1.066-0.203-1.474-0.61c-0.813-0.813-0.813-2.132,0-2.946l5.893-5.895  c0.813-0.814,2.132-0.814,2.945,0c0.814,0.814,0.814,2.132,0,2.946l-5.892,5.895C62.524,17.505,61.991,17.708,61.459,17.708z\"></path>\n                        <path d=\"M75,31.25h-8.333c-1.151,0-2.083-0.932-2.083-2.083s0.932-2.084,2.083-2.084H75  c1.151,0,2.084,0.933,2.084,2.084S76.151,31.25,75,31.25z\"></path>\n                        <path d=\"M52.084,58.333H43.75c-1.151,0-2.083,0.933-2.083,2.084S42.599,62.5,43.75,62.5h1.042  c0,1.151,0.932,2.083,2.083,2.083h2.084c1.151,0,2.083-0.932,2.083-2.083h1.042c1.151,0,2.083-0.932,2.083-2.083  S53.235,58.333,52.084,58.333z\"></path>\n                        <path d=\"M58.655,39.01c2.38-2.596,3.845-6.045,3.845-9.843c0-8.055-6.529-14.583-14.583-14.583  s-14.583,6.529-14.583,14.583c0,3.674,1.369,7.021,3.61,9.584c0.975,1.097,4.723,5.54,4.723,9.166c0,1.151,0.932,2.083,2.083,2.083  h8.334c1.151,0,2.083-0.932,2.083-2.083V47.8C54.237,44.439,57.441,40.416,58.655,39.01z\"></path>\n                    </svg>\n                </a>\n            </div>\n            <div class=post-info>\n                <div class=post-age></div>\n                <div class=page-count></div>\n                <div class=ugoira-duration></div>\n                <div class=ugoira-frames></div>\n                <div class=image-info></div>\n            </div>\n           \n            <div class=tag-list></div>\n            <div class=description></div>\n        </div>\n    </div>\n\n    <div class=\"thumbnail-container noise-background\" hidden>\n        <div class=\"thumbnail-ui\">\n            <div style=\"flex: 1;\"></div>\n\n            <div class=thumbnail-ui-box>\n                <div class=\"data-source-specific avatar-container\" data-datasource=\"artist illust\"></div>\n                <a href=# class=\"data-source-specific image-for-suggestions\" data-datasource=related-illusts>\n                    <img src=#>\n                </a>\n\n                <div class=\"displaying title-font\"></div>\n\n                <div class=button-row>\n                    <a class=\"disable-ui-button popup\" data-popup=\"Return to Pixiv\" href=\"#no-ppixiv\">\n                        <svg class=\"pixiv-icon icon-button grey-icon\" xmlns=\"http://www.w3.org/2000/svg\" width=\"50\" height=\"50\" viewBox=\"-2 6 50 50\">\n                            <path d=\"M35.93 16.864a14.682 14.682 0 0 1 5.087 11.185c.013 4.453-2.118 8.348-5.424 10.957-3.306 2.624-7.75 4.076-12.636 4.076-5.56 0-10.718-2.023-10.718-2.023v6.527c.953.279 2.517.876 1.524 1.869H6.24c-.983-.983.46-1.562 1.55-1.87V19.918c-2.53 1.943-3.827 3.627-4.49 4.877.768 2.447-.681 2.326-.681 2.326L0 22.965s9.294-10.537 22.957-10.537c5.242 0 9.753 1.623 12.973 4.436zm-3.986 19.958c2.286-2.275 3.57-5.241 3.584-8.843-.011-3.696-1.213-6.876-3.413-9.27-2.205-2.379-5.465-3.947-9.617-3.95-3.418-.007-7.65 1.135-10.259 2.988v20.758c2.378 1.17 5.98 1.999 10.259 1.996 3.835.003 7.16-1.424 9.446-3.68zM47.67 12.428c.68 0 1.232.551\"/>\n                        </svg>\n                    </a>\n\n                    <div class=navigation-menu-box>\n                        <svg class=\"icon-button grey-icon\" xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 -1 32 32\" style=\"\">\n                            <path d=\"M 6 6 h 20 v 4 h -20 v -4\"/>\n                            <path d=\"M 6 13 h 20 v 4 h -20 v -4\"/>\n                            <path d=\"M 6 20 h 20 v 4 h -20 v -4\"/>\n                        </svg>\n                        <div class=\"popup-menu-box\">\n                            <div class=hover-area></div>\n                            <div class=option-list>\n                                <a class=box-link href=\"/new_illust.php#ppixiv\">New works</a>\n                                <a class=box-link href=\"/bookmark_new_illust.php#ppixiv\">New works by following</a>\n                                <a class=box-link href=\"/bookmark.php?p=1#ppixiv\">Bookmarks</a>\n                                <a class=box-link href=\"/discovery#ppixiv\">Recommended works</a>\n                                <a class=box-link href=\"/ranking.php#ppixiv\">Rankings</a>\n                                <!-- This links to the bookmark page for the user we're currently viewing, if any. -->\n                                <a class=\"box-link user-bookmarks\" href=\"#\" hidden></a>\n\n                                <div class=\"navigation-search-box\" style=\"padding: .25em; margin: .25em;\">\n                                    <div class=search-box>\n                                        <input class=search-tags placeholder=Search>\n                                        <span class=search-submit-button>\ud83d\udd0d</span>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div class=settings-menu-box>\n                        <svg class=\"grey-icon icon-button\" width=\"24\" height=\"24\" viewbox=\"-1 0 16 16\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path id=\"a\" d=\"m14,9.3l0,-2.57l-1.575,-0.264c-0.117,-0.44 -0.292,-0.848 -0.496,-1.2l0.93,-1.285l-1.81,-1.84l-1.31,0.908c-0.38,-0.205 -0.79,-0.38 -1.196,-0.497l-0.259,-1.552l-2.568,0l-0.263,1.578c-0.437,0.117 -0.816,0.293 -1.196,0.497l-1.282,-0.905l-1.838,1.81l0.934,1.287c-0.2,0.38 -0.376,0.79 -0.493,1.228l-1.578,0.235l0,2.57l1.575,0.264c0.117,0.438 0.292,0.818 0.496,1.198l-0.93,1.315l1.809,1.813l1.312,-0.938c0.38,0.205 0.787,0.38 1.224,0.497l0.26,1.551l2.566,0l0.263,-1.578c0.408,-0.117 0.817,-0.293 1.196,-0.497l1.315,0.935l1.81,-1.812l-0.935,-1.315c0.203,-0.38 0.38,-0.76 0.495,-1.2l1.544,-0.23l0,-0.003zm-7,1.407c-1.488,0 -2.683,-1.2 -2.683,-2.69s1.225,-2.69 2.683,-2.69c1.458,0 2.683,1.198 2.683,2.69c0,1.49 -1.195,2.688 -2.683,2.688l0,0.002z\"/>\n                        </svg>                        \n\n                        <div class=\"popup-menu-box\">\n                            <div class=hover-area></div>\n                            <!-- This could be generalized to a widget, but we don't have enough\n                                 options yet. -->\n                            <div class=option-list>\n                                <div class=\"toggle-big-thumbnails box-link\">\n                                    <span class=on>\u2611</span>\n                                    <span class=off>\u2610</span>\n                                    <span style=\"margin-left: 2px;\">Big thumbnails</span>\n                                </div>\n\n                                <div class=\"toggle-light-mode box-link\">\n                                    <span class=on>\u2611</span>\n                                    <span class=off>\u2610</span>\n                                    <span style=\"margin-left: 2px;\">Light mode</span>\n                                </div>\n\n                                <div class=\"toggle-thumbnail-zooming box-link\">\n                                    <span class=on>\u2611</span>\n                                    <span class=off>\u2610</span>\n                                    <span style=\"margin-left: 2px;\">Thumbnail zooming</span>\n                                </div>\n\n                                <div class=\"toggle-thumbnail-panning box-link\">\n                                    <span class=on>\u2611</span>\n                                    <span class=off>\u2610</span>\n                                    <span style=\"margin-left: 2px;\">Thumbnail panning</span>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"data-source-specific\" data-datasource=discovery>\n                    <a class=\"box-link popup\" data-type=all data-popup=\"Show all works\" href=\"?mode=all#ppixiv\">All</a>\n                    <a class=\"box-link popup\" data-type=safe data-popup=\"Show all-ages works\" href=\"?mode=safe#ppixiv\">All ages</a>\n                    <a class=\"box-link popup\" data-type=r18 data-popup=\"Show R18 works\" href=\"?mode=r18#ppixiv\">R18</a>\n                </div>\n\n                <div class=\"data-source-specific\" data-datasource=new_illust>\n                    <a class=\"box-link popup\" data-type=new-illust-type-all data-popup=\"Show all works\" href=\"#\">All</a>\n                    <a class=\"box-link popup\" data-type=new-illust-type-illust data-popup=\"Show illustrations only\" href=\"#\">Illustrations</a>\n                    <a class=\"box-link popup\" data-type=new-illust-type-manga data-popup=\"Show manga only\" href=\"#\">Manga</a>\n                    <a class=\"box-link popup\" data-type=new-illust-type-ugoira data-popup=\"Show ugoira only\" href=\"#\">Ugoira</a>\n\n                    <a class=\"box-link popup\" data-type=new-illust-ages-all data-popup=\"Show all-ages works\" href=\"#\">All ages</a>\n                    <a class=\"box-link popup\" data-type=new-illust-ages-r18 data-popup=\"Show R18 works\" href=\"#\">R18</a>\n                </div>\n                \n                <div class=\"data-source-specific\" data-datasource=rankings>\n                    <div>\n                        <span class=nav-tomorrow>\n                            <a class=\"box-link popup\" data-popup=\"Show the next day\" href=\"#\">Next day</a>\n                        </span>\n\n                        <span class=nav-today></span>\n\n                        <span class=nav-yesterday> <!-- so box-link's display style doesn't override hidden -->\n                            <a class=\"box-link popup\" data-popup=\"Show the previous day\" href=\"#\">Previous day</a>\n                        </span>\n                    </div>\n\n                    <div class=\"checked-links\">\n                        <a class=\"box-link popup\" data-type=content-all data-popup=\"Show all works\" href=\"#\">All</a>\n                        <a class=\"box-link popup\" data-type=content-illust data-popup=\"Show illustrations only\" href=\"#\">Illustrations</a>\n                        <a class=\"box-link popup\" data-type=content-ugoira data-popup=\"Show ugoira only\" href=\"#\">Ugoira</a>\n                        <a class=\"box-link popup\" data-type=content-manga data-popup=\"Show manga only\" href=\"#\">Manga</a>\n                    </div>\n\n                    <div class=\"checked-links\">\n                        <a class=\"box-link popup\" data-type=mode-daily data-popup=\"Daily rankings\" href=\"#\">Daily</a>\n                        <a class=\"box-link popup\" data-type=mode-daily-r18 data-popup=\"Show R18 works (daily only)\" href=\"#\">R18</a>\n                        <a class=\"box-link popup\" data-type=mode-weekly data-popup=\"Weekly rankings\" href=\"#\">Weekly</a>\n                        <a class=\"box-link popup\" data-type=mode-monthly data-popup=\"Monthly rankings\" href=\"#\">Monthly</a>\n                        <a class=\"box-link popup\" data-type=mode-rookie data-popup=\"Rookie rankings\" href=\"#\">Rookie</a>\n                        <a class=\"box-link popup\" data-type=mode-male data-popup=\"Popular among males\" href=\"#\">Male</a>\n                        <a class=\"box-link popup\" data-type=mode-female data-popup=\"Popular among females\" href=\"#\">Female</a>\n                    </div>\n                </div>\n                 \n                <div class=\"data-source-specific\" data-datasource=bookmarks>\n                    <div class=bookmarks-public-private>\n                        <a class=\"box-link popup\" data-type=public data-popup=\"Show public bookmarks\" href=#>Public</a>\n                        <a class=\"box-link popup\" data-type=private data-popup=\"Show private bookmarks\" href=#>Private</a>\n                    </div>\n                </div>                \n\n                <div class=\"data-source-specific\" data-datasource=\"bookmarks bookmarks_new_illust\">\n                    <div class=bookmark-tag-selection>\n                        <span>Bookmark tags:</span>\n                        <span class=bookmark-tag-list></span>\n                    </div>\n                </div>\n\n                <div hidden>\n                <div class=\"data-source-specific\" data-datasource=artist>\n                    <div class=search-options-row>\n                        <a class=\"box-link popup\" data-type=works data-popup=\"Show all works\" href=#>Works</a>\n                        <a class=\"box-link popup\" data-type=manga data-popup=\"Show manga only\" href=#>Manga</a>\n                        <a class=\"box-link popup\" data-type=ugoira data-popup=\"Show ugoira only\" href=#>Ugoira</a>\n\n                        <div class=member-tags-box>\n                            <span class=box-link>Tags</span>\n                            <div class=popup-menu-box>\n                                <div class=hover-area></div>\n                                <span class=post-tag-list></span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                </div>\n                 \n                <div class=\"data-source-specific\" data-datasource=search>\n                    <div class=\"search-page-tag-entry search-box\">\n                        <div class=tag-search-box>\n                            <input class=search-tags placeholder=Tags>\n                            <span class=search-submit-button>\ud83d\udd0d</span>\n                        </div>\n\n                        <div class=search-tags-box style=\"display: inline-block;\">\n                            <span class=box-link>Related tags</span>\n                            <div class=popup-menu-box>\n                                <div class=hover-area></div>\n                                <div class=related-tag-list></div>\n                            </div>\n                        </div>\n                    </div>\n                    \n\n                    <!-- We don't currently have popup text for these, since it's a little annoying to\n                         have it pop over the menu. -->\n                    <div class=search-options-row>\n                        <div class=ages-box>\n                            <span class=box-link>Ages</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=ages-all data-default=1 href=\"?mode=all#ppixiv\">All</a>\n                                    <a class=box-link data-type=ages-safe href=\"?mode=safe#ppixiv\">All ages</a>\n                                    <a class=box-link data-type=ages-r18 href=\"?mode=r18#ppixiv\">R18</a>\n                                </div>\n                            </div>\n                        </div>\n                       \n                        <div class=popularity-box>\n                            <span class=box-link>Popularity</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=order-newest data-default=1 href=\"?order=all#ppixiv\">Newest</a>\n                                    <a class=box-link data-type=order-oldest data-default=1 href=\"?order=all#ppixiv\">Oldest</a>\n                                    <a class=\"box-link premium-only\" data-type=order-male href=\"?order=popular_male_d#ppixiv\">Male</a>\n                                    <a class=\"box-link premium-only\" data-type=order-female href=\"?order=popular_female_d#ppixiv\">Female</a>\n                                </div>\n                            </div>\n                        </div>\n\n                        <div class=type-box>\n                            <span class=box-link>Type</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=search-type-all data-default=1 href=\"?type=all#ppixiv\">All</a>\n                                    <a class=box-link data-type=search-type-illust href=\"?type=illust#ppixiv\">Illustrations</a>\n                                    <a class=box-link data-type=search-type-manga href=\"?type=manga#ppixiv\">Manga</a>\n                                    <a class=box-link data-type=search-type-ugoira href=\"?type=ugoira#ppixiv\">Ugoira</a>\n                                </div>\n                            </div>\n                        </div>\n\n                        <div class=search-mode-box>\n                            <span class=box-link>Search mode</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=search-all data-default=1 href=\"?#ppixiv\">Tag</a>\n                                    <a class=box-link data-type=search-exact href=\"?s_mode=s_tag_full#ppixiv\">Exact tag match</a>\n                                    <a class=box-link data-type=search-text href=\"?s_mode=s_tc#ppixiv\">Text search</a>\n                                </div>\n                            </div>\n                        </div>\n\n                        <div class=size-box>\n                            <span class=box-link>Image size</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=res-all data-default=1 href=\"#\">All</a>\n                                    <a class=box-link data-type=res-high href=\"?wlt=3000&hlt=3000#ppixiv\">High-res</a>\n                                    <a class=box-link data-type=res-medium href=\"?wlt=1000&wgt=2999&hlt=1000&hgt=2999#ppixiv\">Medium-res</a>\n                                    <a class=box-link data-type=res-low href=\"?wgt=999&hgt=999#ppixiv\">Low-res</a>\n                                </div>\n                            </div>\n                        </div>\n                        \n                        <div class=aspect-ratio-box>\n                            <span class=box-link>Aspect ratio</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=aspect-ratio-all data-default=1 href=\"?ratio=0.5#ppixiv\">All</a>\n                                    <a class=box-link data-type=aspect-ratio-landscape href=\"?ratio=0.5#ppixiv\">Landscape</a>\n                                    <a class=box-link data-type=aspect-ratio-portrait href=\"?ratio=-0.5#ppixiv\">Portrait</a>\n                                    <a class=box-link data-type=aspect-ratio-square href=\"?ratio=0#ppixiv\">Square</a>\n                                </div>\n                            </div>\n                        </div>\n\n                        <div class=\"bookmarks-box premium-only\">\n                            <span class=box-link>Bookmarks</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <!-- The Pixiv search form shows 300-499, 500-999 and 1000-.  That's not\n                                     really useful and the query parameters let us filter differently, so we\n                                     replace it with a more useful \"minimum bookmarks\" filter. -->\n                                <div class=\"option-list min-bookmarks\">\n                                    <a class=box-link data-type=bookmarks-all data-default=1 href=\"#ppixiv\">All</a>\n                                    <a class=box-link data-type=bookmarks-100 href=#>100+</a>\n                                    <a class=box-link data-type=bookmarks-250 href=#>250+</a>\n                                    <a class=box-link data-type=bookmarks-500 href=#>500+</a>\n                                    <a class=box-link data-type=bookmarks-1000 href=#>1000+</a>\n                                    <a class=box-link data-type=bookmarks-2500 href=#>2500+</a>\n                                    <a class=box-link data-type=bookmarks-5000 href=#>5000+</a>\n                                </div>\n                            </div>\n                        </div>\n                       \n\n                        <div class=\"time-box premium-only\">\n                            <span class=box-link>Time</span>\n                            <div class=\"popup-menu-box\">\n                                <div class=hover-area></div>\n                                <div class=option-list>\n                                    <a class=box-link data-type=time-all data-default=1 href=\"#\">All</a>\n                                    <a class=box-link data-type=time-week href=\"#\">This week</a>\n                                    <a class=box-link data-type=time-month href=\"#\">This month</a>\n                                    <a class=box-link data-type=time-year href=\"#\">This year</a>\n                                    <style>\n                                        .years-ago {\n                                            padding: .25em;\n                                            margin: .25em;\n                                            white-space: nowrap;\n                                        }\n                                        /* These links are mostly the same as box-link, but since the\n                                         * menu background is the same as the box-link background color,\n                                         * we shift it a little to make it clear these are buttons. */\n                                        .years-ago > a {\n                                            padding: 4px 10px;\n                                            background-color: #444;\n                                        }\n                                        body.light .years-ago > a {\n                                            background-color: #ccc;\n                                        }\n                                    </style>\n                                    <div class=years-ago>\n                                        <a class=box-link data-type=time-years-ago-1 href=\"#\">1</a>\n                                        <a class=box-link data-type=time-years-ago-2 href=\"#\">2</a>\n                                        <a class=box-link data-type=time-years-ago-3 href=\"#\">3</a>\n                                        <a class=box-link data-type=time-years-ago-4 href=\"#\">4</a>\n                                        <a class=box-link data-type=time-years-ago-5 href=\"#\">5</a>\n                                        <a class=box-link data-type=time-years-ago-6 href=\"#\">6</a>\n                                        <a class=box-link data-type=time-years-ago-7 href=\"#\">7</a>\n                                        <span>years ago</span>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                        \n                        <a href=# class=\"reset-search box-link popup\" data-popup=\"Clear all search options\">Reset</a>\n                    </div>\n                </div>\n            </div>\n            <div style=\"flex: 1;\">\n            </div>\n        </div>\n\n        <ul class=thumbnails></ul>\n    </div>\n\n    <div hidden class=templates>\n        <div class=template-bookmark-tag-entry>\n            <div class=bookmark-tag-entry>\n                <span class=tag-name></span>\n                <a class=remove href=#>X</a>\n            </div>\n        </div>\n        <ul class=template-thumbnail>\n            <li class=thumbnail-box>\n                <div class=thumbnail-inner>\n                    <a class=thumbnail-link href=#>\n                        <img class=thumb>\n                        <div class=ugoira-icon hidden></div>\n                        <div class=page-count-box hidden>\n                            <span class=page-icon></span>\n                            <span class=page-count></span>\n                        </div>\n                        <div class=muted>\n                            <span>Muted:</span>\n                            <span class=muted-label></span>\n                        </div>\n                    </a>\n                </div>\n            </li>\n        </ul>\n\n        <div class=template-muted>\n            <div class=mute-display>\n                <img class=muted-image>\n                <div class=muted-text>\n                    <Span>Muted:</span>\n                    <span class=muted-label></span>\n                </div>\n            </div>\n        </div>\n\n        <!-- A user avatar, with a follow/unfollow UI. -->\n        <div class=\"template-avatar\">\n            <div class=\"follow-container\">\n                <a href=# class=avatar-link>\n                    <img class=avatar alt=\"\" style=\"margin-left: auto; display: block;\">\n                </a>\n                <div class=\"popup-menu-box follow-popup\">\n                    <div class=hover-area></div>\n                    <div class=following>\n\n                        <div class=\"button unfollow-button\">\n                            Unfollow\n                        </div>\n                    </div>\n                    <div class=not-following>\n                        <div class=\"button follow-button public\">\n                            Follow\n                        </div>\n                        <div class=\"button follow-button private\">\n                            Follow&nbsp;Privately\n                        </div>\n                        <input class=\"folder premium-only\" placeholder=\"Folder\">\n                        </input>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <div class=template-tag-dropdown>\n            <div>\n                <!-- This is to make sure there isn't a gap between the input and the dropdown,\n                     so we don't consider the mouse out of the box when it moves from the input\n                     to the autocomplete box. -->\n                <div class=hover-box style=\"top: -10px; width: 100%; z-index: -1;\"></div>\n                    \n                <div class=input-dropdown>\n                    <div class=input-dropdown-list>\n                        <!-- template-tag-dropdown-entry instances will be added here. -->\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <div class=template-tag-dropdown-entry>\n            <div class=entry>\n                <a class=tag></a>\n                <a class=remove-history-entry href=#>X</a>\n            </div>\n        </div>\n\n        <div class=template-manga-thumbnail>\n            <div class=manga-thumbnail-box>\n                <img class=manga-thumb>\n            </div>\n        </div>\n    </div>\n</div>\n"
};
var binary_data = 
{
    "activate-icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC4AAAAsCAYAAAAacYo8AAAACXBIWXMAAC4jAAAuIwF4pT92AAAGU2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yN1QwMjoyMjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjdUMDI6MjY6MjAtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6N2ZkYzUwY2ItYjgzMy1hNzQzLTllMjYtNzQ1NmM4NDFlNjM0IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MzMyMzRmNjktNjk2OS1jNjQ1LWI0MjgtYmM1NDUwYTM3NDAzIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDozMzIzNGY2OS02OTY5LWM2NDUtYjQyOC1iYzU0NTBhMzc0MDMiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjdUMDI6MjI6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MWU4MmI3MjgtOTVjNi1mNzQyLWJjOWQtMjIwMTM5NzJkNDBlIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTI3VDAyOjI2OjIwLTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PmQ/KUAAAAQhSURBVFiF7ZlNTxtHGMd/s/aaGGqDHRRLwQergko+lJhW4pJDqdQLpzSfoOHqU/sJGj5By8VXkk8QcuqlUtxjckBWLxxaqVYFVQnCdWiDYy/e6WG8Zr07a68X8xIpfwmtZp6Z2d8+fuaZF4SUkvdRxnUDRNV7Cx4HEEJMbsRKpwTMAc7TUb3316ScqEUd3gltIaW8GHil8zXwBbCGgg2rKvAc2KGcqIftdDHwSmcN+AZ4NF7HQO0AW5QT1VENo4FXOo+A74GCzizaDWg3EN1TOGsNGuNJZOI2cioL8eSwD9ignGhOBnwIsGgdIt4dIlqvwbaGj+O8fCqLTC+pj/Cr2YPfiQ6uJtoPqPj1ARtv9vyeHUMymcPOfAqGqTNvUE488fUZCV7pPEZ52SfRbmAcvYwMPKB4Evv2Z0gzrbP64IPBK5054BkaLzsKAy6TOWR60Qek7WuYdO/cD4r9FXf6dMDjA01UaLxgMP82PeXhwDML2JnlfvnjuzESpqBjSQ6ObNpk6ebXVagd76pGtoVxvIudu68bchtY8Vaer5xqAnqha8BGWOhufh07s0wmZbBaNFktmszPGqSnBfOzBvcW46wWVTzLZA45s9DvK6wTxH913bClHpsGXBm2PdA7lBMrKI8PB777Fd38er+8lI8Nbe/A25nlAS8bJ78HdfHNNcfj2576GiE93c2vg2EiWodhmvf1+Sc9z7vngG0h3h7omhd6K3RfDvimq64JPBy2CPjUi9FxFHP9KO45Id4FOuCBu+CA/4jaAAFUx9k7GEcvEda/YZtrNRDr7UZQs9LAewF63nVCY26cl040p4NaffUrcMldOM8qaoOzCTydHEVIeVbfML/gYB4vJx5PkiesjDd74RqqdaYG13gCerWnwkG0G+EzkmsFvRZwBxrb0s4PaaZGjhEf2SKCXu1ZTN8SLMwbZFLnvvnzsMvfDbtfjv31s7+zYQbtFuvuwqWAG//8yulskd/2TaDrtx/vBoaHTN4JGrbqLlwKuHh7QEy/Ao6UnM4HmZ67CzfqekJOZYNORXXviehSwN0brnEk54pBpk1vxURDJbb/U+S+dmY56BRU1R3hbkSoyI8KA/sVl5rAQ53hUiZnaBkm9mxxGPSXQbvUSODSTGGnlzBO9yOf8uXMAnZqKeicWUdtrWtB/aN53DCR6UW66UWEdQKt1+p51lLPINiprDqy3cpd6FIoOrgbxkyDmcZ7yy6sE7DPem1SQauhW3Xgu6CLIK+igteALdSpZA3NHj4gQ+i0AzwNC+woKnizl6KeAM4laAm4h7qmK6E/kDRRH10DfkGlumYUgMlkFXUIqU5krJC6EXk8ij6AX7WGg6sL0AcaS6E3Ia9Nozz+B/Ctpr4AvKDS0dmuRKOyytYIe21CHGNLfPjP8hXrf5SZd4NRInfBAAAAAElFTkSuQmCC", 
    "favorited_icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAGbWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTFkYzgwNjgtMDM1Ny0xNzRlLTlmMDAtNThjYjk4NTQ4OWQ2IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6NzdlYmJlZGEtYjQ4Yy0yYzRkLTk2MTQtYmM3NmZmN2VjYTU5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0Ij4gPHBob3Rvc2hvcDpUZXh0TGF5ZXJzPiA8cmRmOkJhZz4gPHJkZjpsaSBwaG90b3Nob3A6TGF5ZXJOYW1lPSLimIUiIHBob3Rvc2hvcDpMYXllclRleHQ9IuKYhSIvPiA8L3JkZjpCYWc+IDwvcGhvdG9zaG9wOlRleHRMYXllcnM+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDFkMGQ2NzQtMTBkNC00MDQ1LTliZGQtNTY2ZDYxZTNlYTU0IiBzdEV2dDp3aGVuPSIyMDE4LTA2LTIwVDAwOjU2OjI5LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMWRjODA2OC0wMzU3LTE3NGUtOWYwMC01OGNiOTg1NDg5ZDYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDE6MjM6NTktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+LpZUDAAABwxJREFUeJzlm29sG2cdxz93tnOOHedP4yZuq7QpibsSBeKWsVWZyqAQaWKTJmBVEjQoSBOVKAiYqkkVlYbGEO94AWKiiGkvOmnrRMUfsSpNUatVtEQg1JCkLWFZk5JiO2qSJk5jJznfHS98h52ssc/2+W6wr2RZ57t7nu/zfb6/3/NPFjj+FhvgA14GngMCG2/+jyIF/B74NnA394a44cGjwCLwPf5/Gg9QDfQCM8DPAcG4kSvAb4FfAG47mdkMAfgm8A/0thsCHAWedoiUE9gD/A4yAviBVxyl4wyeAlpF4Je8Pxd8WPCmCHzRaRYOYr8IeJ1m4SA8jmR8yS1T5UojudOspt2sKW5W0x4nqDgz5LXUz9HeGKc9GGdiNsTEXIiJ2ZATVOwXwONSONQ+xtEDF9i/Y4q/R3dyaqiH6YVGR1xge/YP+hN8q3uA/TsmARdd22/znYPn2OpP2E0FcECAjuY7fGzbv8jORgUe2hqlMzRtNxXAZgE8LoWe8Ih+Jaz7/mx4FI9LsZMOYLMAQX+CIw+/s6HqzPdXP3GZoANhYKsAHc13CAUWyFmM6RBoqlmko/mOnXQAGwV4sP1Zd90THrE9DGwT4MH2X0/jyMPv2B4Gtgmwuf0NCIQCC7aHgS0C5Lc/6363OwxsEaDeu0z/visFqnQmDGwRYM/WGDvrZ9m89w3YHwYVF8AlqhzcfVO/KiwAwKG2MVyiWlFeBiouQJ03SW/kqn5VSIAMncNdQ9R5kxXlZaDgatDjUvBXrdDou0+tN0m1Z61gM3IR2T5FZPuUfmXuzXAwRn/kCsPRVtP1aEBKriKx4mMuWcPymhdZcRV8T+D4W1q+BwJSisj2KXojV3nukYtIbtk0qfUQMWc4Vf8Uj9W0h1/95RBnhrsZjraytFptilVeeN0ynaFpjnUPIrnTZHqx1I8ZlF6+5E5zrHuQztA0XpMdVTAENARqpBUyJnMV0ZBSIej1lAINUKiRVtBM8izogJRcxWhsJ/GlekChVHtWHiqgEF+qZzS2k5RcZeqtggKspD2MxVt46cIz6yr6YCHbMS9deIaxeAsrJrfXCiZBAwEpxSdb3uNHT7zBgV3v6r/aERL5kLE8wNDtMN8f6Oev022mkp8BF92Hf2DmwTXFQzTRwOVbHSRlicdax3UC4IwI2dHiJ5ef4sXBXkZiu0jKUlGlmHaAAVHQaPQt0bNnhNd6X6HKZYwMpSauUqAAGpomcOTMMQb/2cXd+7WoWvEdYdoBBjQEkrLErflmzo9H2FE3T3swTqY3ihnuSkHW8ufHu/jamWNceq+Teym/6ay/EUULYEBW3MSXGrh6+yHmkgE+03ZdJ6hRmRl2NtH98I9f4seXvsCNGfPJbjMUHQIbIQoaW3z3+XTbdX769Gtsq72n37EqQWpkGq6xuOLj6K+/wcWJTuaSgZIsvxFlnwypmsDscoBzN/cxOd/E6/0/Y2/Tv8n0mBUHTxnLT8430ff6dxmLtxSd6PLBMq8mZYmxeAvnx7ssLjpTzvnxLssbny3dIvirVjn4EbNrf7MwDk7GqKu2folsqQDtjXH9zA+sFUAgHIxVZKfIMgFcosrjbTesLlZH5TZMLWNa503SFzE2Pq2eC1Ruw9QyASpj/1xUZsPUEgFKs78KpDG/vK5MGFgiQHH2N6azRsON5XWh+VhlwsASAczb32i8xsWJTo7/4StcnOhc93t+WB8GZU/VzNs/28CTA328MfwY8UQ9vxl7hP7IFV5+4k39mXwrSwHQ6AmPcPlWh6ld30Io2wGF7Z/t3dnlAIdPP8+poR6m5ptIyhJT802cGurh8OnnmV0OkN8N1odB2QLkt3+2MWdHH+XJV09w7uY+ZpezC5nctcSTr57g7Oij5BfB2jAoS4D89lcwFjInB/p44e1nuRbdvelcPilLXIvu5oW3n+XkQN/7ysjC2tGgLAECUorPhUf1K6P3N7d8oZiVFZeJkMhQ/vzea2zx3S+Hfk5pJaLOm9RPfcFIUIUsXwj5Q8IYOgV2Ndwl6Cs/D5QlgNctMznfRKbxxVn+AUjr3yqg5obEiXNfzrmVGSkm5kL4qtbKoQ+UsSUGUO1ZQ9FEtviWcQkqF979OC8O9nJ29ACxRAOKWpS+xsP/3VhUNZHFFT/XZ1q4MrUXl6gS9Ce4vdDE6b99iuFoK/PJmlLp65WVsSUWkFI0BxZprllE0URmluqYWaqzfNMCMm7bVnuPbbX38LplYokGYkv1LKT8ZZVblgCioCEIGqKgoWoCmiZYsk9nZ31lzQRVTQBNsO2grBL1iXzwDvrshCICf3aahYOYFMn8o/LDiq+LQBQ44zQTB3AL+JMx9vYDzvxjwRksAx+F7ORDA1qBSw4RshMTQCOwBuunwipwCOhwgFSloQA3gMeBMLBq3PgPsH6q+iD6RQEAAAAASUVORK5CYII=", 
    "noise-light.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAD1BMVEXo6Ojn5+fo6Ojn5+fn5+cbG+42AAAACXBIWXMAAAsTAAALEwEAmpwYAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yNlQyMzowMjo0My0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDctMDFUMTc6MjI6MzctMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIyIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OWM1ZTI1ZTktNmM0Ny1lMTRkLWI1NmYtMWM3MWRhOTk3NzAxIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ODcxMzVhMGQtMTYwMy1hODRhLTliZWMtNGJhMDllZDE1NGQ2IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MTEzYjRiYTgtMmYzNC0zZTQ2LTgyZmUtNTM2ZDNlZDgzYTlmIj4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxMTNiNGJhOC0yZjM0LTNlNDYtODJmZS01MzZkM2VkODNhOWYiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjZUMjM6MDI6NDMtMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjljNWUyNWU5LTZjNDctZTE0ZC1iNTZmLTFjNzFkYTk5NzcwMSIgc3RFdnQ6d2hlbj0iMjAxOC0wNy0wMVQxNzoyMjozNy0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5BpB4MAAAWVUlEQVR4nKWb25osuXGrfyD4/k9cBHwRzDXy/rw9GvmuR+pVncWMA04cTq2C5I5wXU5HHRDgnmL11BVjMK4BMHY1cFAFTCU4OQVOjEd2ZVdIs3/JPZURwhpF2KoYUDUXMDkXJGlQVHxVixbXtqIpo5rpJArIBQtfh4FiEairACgUaaIgcHGYwQRcpVJ8B1oQnOJQdQD1BDuI1CkuwZf4GnRUVVKLW3D3b8oR/U4TI4JVFeFqptSFY4AWWWIaWRSpakdWc3yd/TVMcPfPnKjnJ1edVgj3KGIouqjAAe+HqaLaf23gICRG+HCMZIE0GGGMtO/nSIPH2n+h8QFsg3QkAYP2B/ZLwwgbhJGNYYyNbQbPSTFVXAqRjXMqCjiyHKjVUtx0VE6mIZLUuqAOlba0wENlSVWx+ypILWoBOSrqpGiPEUmZtOn5TTsaA9l2KALTURVMqxNX1GDNJUK0ruL6FlH0ykHKRCChCrtBljqcgFQNvoD39RRRSY50OeK4pexfYFrV6WSoahXrO8aqCtLUrZCDwlwxouIYRZMKCXSExmZ0wAySYeSzvT6MhNH7b8bnGDiy9pcN1tbDjL3vX8egM0doOOcwgGUADbOfDZ067lxnsDtVPIVMrVIBQo2RKmV+mSCk8grS2WptVPA4JoCTFjA3THnnJYAJYgRVpHbiyk2mkMKooMl+MuhPO++LFVuolagclxqQ0mjocVUGcZRTh/0R1QgyyghTqZ07glSn0sBMKAd9dTsUJKzgClUxp1Lp1y8FE4EoEeHcKY1KVZeGU9nBJh6oTnHltlQEo7szxkmsjF6/CITjUEGNrqrKilt3j1ej8o1cEU8OlUQR01ahvn++0X1FX80P1fse20NaDck+TanrH3KrbJtJ9Q8p7Ai45oLA15kG0PwEVIqcb5JWFN/ZF3myc7MnuEyl6iRIRCoqhsJ+pNlPUutk6gBWdKGcOurUmVhOTyVhRzlSjAmKqo7ZAqJ4+9Z2K4brSEHtUN4pYUup7lRGqk1OxeDtDIFy3nccoNRhl9RcIlWxcqg9Ffb9Xo7rzmVrIqKFE2e6q97OG9Fon2eboxUEGEXApLP/g1qd/cVR5hqo+rpf7wl337qMqqoj9M56h1/anTbdXqxOWw5OsV9rjhr2wd2myBS1mrhzK1oLy9TxPQo+eKzDSJakebPqwIA5oBECwegIbAnNTkCLGWSwvasUfHaOImxjjkYWx9qpysEgM1Rz6+CIanbrML5vukVvpg2TsmevutOpUdyiTmdxTU8sXVxLeC49+Hr3a9Td9tFErsbSnVZwMlSyKeyr4VX6qE5E0dmBt02k6g1H6I4KFCngqKLzU9VGdWSKd2tVFMUahbPzNciivpEmeQB1ZLXjeyK2m/e9TtS66sQunYKMenzVgTr1fWDIivOeuy1GU1yxy07iSHPA2jU2u7J4+GKX5lbDQeDRgH12D75jsDzIfigLS2Mk7z9hmP3tEUcM8qnuzlW4hSwwPtcj7giEnDM/sLmiYZdRx9lvrd3NGrVFinx2eGhadkeRuLrahpPTqTstB8k7hrC3fXvuOG4NkpMYLyivOiUs3pF/b0qq6GrBcB1fMcH0hOgBI1ev7luGRz/a1pfKjibeuZc4O5BAi2Vn31mRuyBouGh3duc14aH6s61irovd6kTZHVod4UtnBjmuqSWglWs7HPBc1WMyMfY9F5yFcaNUurPfsKi6UaU8hNdt83cm+1zu7GANZrI46mExDDOIgcEAC6MljgZpZA72oCPbmoPwyDrIW5dnxhrBGe/QGo4k6Wwt6+N1wPHIleIuq7tConPhtbw9Fu2ohKpCZVql1uVkFBGDmLu7pzW+yjyoT/Ww0u567SjTbBHD7hn/IRPgAzPLHpdEnMVWgw+ah0W3wf6WvYwl9jwY2yOQhOShHueo+Cvr2iUmk/HioHQuiKlR5QtGO7XF6zjFThbtqUYd5BsmOEg4Lpbve4CeB5P/Iaz2v8DqJXLI5y9Ib0seYYZz9il3ohxxRpJ1xhZ4Oq7DeaiQ1B9UW3SYpSKNYEKncjHQxU7AqHVMVCHoYzHq6Go69KEw0J0FD1NpMpM6Ol2gqTeN6sE5KeVIi7MkMYsnXVpFc+UIV+e3OGrPunYeglcz8UTFW3o7JAQmnaIwDe/hVSuTLhfV4LT7XLWUolEcuYhOJpq4mTK06kKewpgeYOIbI4OUg9z9rwTN/mFxsvoJjhVX5XTIfJT2FPVOVToswzc51512qcX++U+puVPSiWAkheXZ6DpbvFKRfQb8n8oEXi7+j2QCPZnA9hlVpW9gQj03s8ChrveUC6N48oDKcqHiaZHqwr4KYEeVzIQRO4K8xzbd/1ucKAvtPfvFJcvbNeMHAoBjefwwwUeG5bO/jg4HYx8L8XrO3wfY9qJFvKoYswcJOhiGuXi/B1ugXtFoSfmur1KerjRxK+osyXeYqz1COWeXjFTPwkUj5ROPZimOKpg7SIM8gikMFUyWzanAtMY90XTKFBHmsaIF9+RkVv960luYi2+dk21FIpieu3tge6hsPfMpX/CHhDV4+X8LRNNWBzdMx84UjB/DUlbsfPLP/FxX+GJ9xGHuIaKVaMVUsxDYl0Sm3Up+4BWuaqP6rDz32xPlJpMBe7/JrJYxT3OZ3jHXtMeJm8uEq1+o0SwWvYWr0U4OVE8Lzk4QkacvTVucCkftMMrCZGgVk9nfoJqqjhXhrh6V4yizdeNAEHbrZHVCm2NmjhGzB/Znq4A80kicA7IPeFYh1EHWnHNYLKJz/ix0rRy1FOu8PsIzKwBjbM74IiBzkagCEj2d69E92h1FEffjpnM1ecIKarqPHvlSjqba0rNKpJjWGFDExGjuguX5C1udGSx/j2pkSwcWPyxpGCR0LD1WiN+e/c+w3QzU+LcIvkuZ7DhIO8hh3AKn5YlM9UJZcDQ7IirFqiZP1dru0An0bcd0TKtxqlaeaknWmLhLM9ppVfVcHOTbbvMP8+tTPhdev2Y1HRelqEdxtiNWSvNb8kcOuqCmUM0d11eKdFc6hakcTRZ1zS4unU5otbBVYMbyLe08XTSr5Omu9lqzo292+lFuaHZX6HRhCWtJlAktJ07L/LztcGVnP00FrMl+rYp8mMik84Nd6aNS90SlE6ZlSIcueGaqukJnJF+e47Dz56Kjxde6XmVCdWM/DZJR5oNdO1JnRaBljXnKk1uvPKeni89O6waGCGWW8lVOdEIj47s+kpXOKocdlNmd4mnnQu0+OT7qrMI99Ox48lUFPa2dKmgavPPqUx+mOJ46g6/1SOSD2eu21O1qUqVGbVlXqNOjavXwZRgUeu4+WDhlhfX3xXWuXDxasYgRdSP79pHlDmsquVMvsDEr5IWKo8xnAqw67SC20QTjUM37N/LCiMAIXXZIL1EUs7tn7lZWyig5iOl6Ifqg5Eqpku7iBoYy+6aEJp9R9cDXbmJTvxpXpR46nczVIvsxq6xvP295+3ZVPLQlhlJ/taZ6xfotr6KcPBOM8+nAXn1OE+HMcK2KpudqPQ8LMdLrdc4nz0nDxJfXvKomR9e8/r/B69p0TaF1EjCzJiNUK5kpEpmw63HE+aES3xLnDfn/A0H9t+21fyGoswYNSIxpUE/rHcp/+bMKtEa+D+2ESk5x8ErCde31lvypz2u+rsxYYdU1q6d3aKqwGM8rDZYn98M8fFnOBXHu6VJZq+jUF2ahVs/qSS1/RPb1PJ7KU1ti0l3Pa1xanXDIGDK7BJ8Wzmk0azZU6GRlbqdvA67GV7eu59da6ro/O63AJSqtZFLmd6qwf5NOaz+3YoWlrnBHp1LMM2CZorksQtwZwGRFtrpMVgxd8X+hiq58/VxGvEhldzxq54qa9IPUVy4dHYidOicLfxA/UyB1m9G+daAOw5mUnj080zWLLsSyTRHRocnxQss5Qu19iF7zgzXcD+C1r0dG0nB8pPPooQaxTvt26443JHnA0gouRzMWg89iIq8ywXbCaqrbOzrPsZytT5iVGKh2xLLWEXr1uCSYiSLhHKZM5yLJ7XSyOFmV7uiyooef27OYakSZeH4jFY8f+D7p5guwXbnzQ0vSVrd/dja71DupxV0nMlLrSC4WbzwP9Oh2eao9IaYT27RKXC+BhMPsPFobYfmjtKrQ2Z/1x7bgIdOPgO6bWHrO+hMW5jt2znoWyJbPcvdPmJqFxKs+LWIXsl85DDrzHmGfbvDxWSd5qfE5o+eqsMLfCltbOyOdJXpzQNYrKTzyDCqZWA/Q5T3znjsdJwsN7blgeq6MWte6GyTJAxhGu49OeJjMeYhQvmU7WBOrkzrMDm79p7bBdyb/xDaYv2wDpshXs/mEdRa5u9yI1i4HnbwdtwXpiOyDSSmNgzivrS8KTkdV5+I8V7neuVFFT1zQSLFCV7VyfFeHUMbKjjp1xYEqp3UrR40D7qHPLn58ZT3mmviZNOJoxX6JNoA3EjPnl0W8VfdEL9N9oifoPZd5pdcdBUc5jzCohMO1/lKBopVByfa/1EwjP1txVhd/esg20pXLobPCRdYJG6JOmJy8MrWXlfeL8WjqW5W56yWLUe8ynHoCltde1uq3ux1qPRb6f+hjvzIU1urG6AyWNOKMeZ23liWrOOuNAMYjVWo/SzYSE99VEfsEyfd+Jd/ZLNLiK6VaULxt63Jq3tIUUWA6VBvxmcRmm6xm5eysvvmyPutosYB5nYVPfWWBhbLTX4TpuZ6rahfKIj7f56q7aKqG05Wkim7t0E5GS/3poAW7fcKtkHW3qZYTDmXhGCvXLpBYU0prsk+cw0Yb5PskTVqRNcczEiszK3LKvI2uWb8FxH4b/zaaJnry8B99UsD04Oo8NLCqXwJvnWb8o2ZzWAtXQS9w0toZJ7irCfsN55cg+kfLwXDMmLE48j9YDp7z6KpAPc8vWGlwBYKVIVhcZVFXUkL8MkZeqX4jAwp1TgI9fyHsVV7ZCMLKJWxd7YT/M8T/A+HbOg8dnXlgwsPMvLU+u2l8vNSQ7xN98LGZdTHY5CUTh7ZH2oSELDaj4udS6dzjq2/ppBtzdGJnG1qkZnzVPpakCCp9UVGnh8w9+H8YLv8MpOxw8RsuYvx3IIU33ODM7AjZLTIRj1EoONZdMqHlNp2VHLSiVKdBJwq2LehRlb70Wa0FhIFzfd866uam9gwZ14tD62zcbIHoFgJz+zQL7wR8PGIWIw6TWtM0jnXrPmTYzyxvOf6xoUPZyTOSvFmIvzMG52/yfn9jDOp/yPstwGEXk/53+Pa3E+J47GH9jech6byB8P+ZEP6XCbEBrvdOVJp92K6fVfL2wVy0uAa341KOadfPq7LJhKr7NiVpfhUvcDEZtWlxVzoETQbNek53y1LpEJN9IvXsys/h8+SA+J42mTCz3HDFiLuDmpc7M9KGIH23KjV5/qOnWycvY/sfmnf/yQzTXxlfjeTY4m0GmBiGrH1K2eyzY/Ey0kV9yVrtpujTLLrrfEebor5gpb9gZc/GkXoUJmYcvXCgVtRi1geCHadtbMh52bAvX2TTtftWf91FsSrHpqyvDVk2vsZJMxv7dFNO1ozmKMv4KqEdGCAn80boGi0ncwvnuh7gytwnhn2Opstm7iPq+wppLp1esDdaMHWVSQ/+x6P3v/PDf2f0/r+4kr9Gv6YsvqGzj73+6BqUqx1K/aTd4jVkn5K5K3gVybmzynR4uLLez+70j0a7ijLZEeDsNhzf7dcHwCRfevoBJMkr70J7VoqOMFaZNVRWydQfdadeLrSjQXMhO5+fa952cp7c2tY9KOdBWEvzY5umJ5IUtUcmTntQnxi/guPMD4EOZTOZL/8E1bRdqLt81Z3WmktlwqBeDr/zM6uMb1/N3dUN6w39QD2/8+ewNjwNnFTL1TaXyIaPfdmg+ZCufKzSuSuxlxkZhZS5VZh7ursKjXAkRVt+G/ibu2yvUqbestdl7oZf682FcqK0z/J3v04DvuiqcGYvVVgtkuauQRKrc1+uzeswa1nHnWUxzjTrBQ5ZmztIfuXTuhyosqztSyK8INhb2zM9fdWyt1AsK2xO5A9M7+banuUNrmaFi6servCmatxmLcx9/+EZGsb7QrtC/+Z1dKfncurPKDbxtdXQ002uul6UyLPhN1SCnc2raDR3avotVufr4cq0ozC/NQPFuZtgXovp2cN/LgvIHCGGnT3S5mfXh9Bo5PFZ/IdXBuRdPxCHY0v2LqqjYeO56z7LDz4uKPTMZn6QMnEXILQnCnlMAHUT/tXm3ojSWU13T86bBDroktbDH/PdjlbDENv6QXAWY2aEwkkzbZG7A6aqmPRQHdrN7PNeyNnp9ycSqX3I5wMJS5sOr1/846m5j1xKutLjzm+gstswK55H+3JEJ0G8q1R1IxHQsApNDh27pRPpFeutRfa+yhMst4ex3Nsd9uPOTPZ2FNoc9zzkY9OU8jiGZjOHoqenNFqTd82AwGHDvu65GoLd9il68buGBekibM39TuBlRF/zqNKUjaOCNmrsqw3sQ96lqZy+tFhXBjzNOhPVRgS80DNvaedp9wpTm5ZhzbEsRvczZew4G4ZdmCJdqemmT5yK6vF47bqrzm/olv9r1ufD8crBK1Ku3KeUkxESxe38uQ9QdDdu2ZPz4tsmXYu8866rnRfD4l8HTbrpccKm4KdrI7/LVcM4HGVjjujf06Q5e60Qc9D4TQDr39Sy/hvm+RRWxJmHp81fSs6KpgNmwmNkC2u/Y98ImvZKhJ3SqXXRlzehq95PxfjueATrRzGTsVAz4+tai+BTabD1QgkpY69X/wxOhxO9KykKPbfro06+oVW5frmRPMVn11Mcyx3q3ijvx3PrTkTHum4Ydc0ipZnVZDekeHGH+SOtrZWy9ANtkMPvXtJLeGpFtMqh+3WuVgnsG+G23yvX+J+xvaWoswvDMoPm3ciwxdHB53BmS+dbISvOnJVNFmPKjnxrMgrvHhd0qHJ+uBPfvbElEa3084I2ed/7vmmoeWCyLkr2Ok4eKe6GTO/eS6xTc5PxZYJu3IO6CDHaRCbgeuVlZaVSdVZxuHvn48PAr3qHW1+6+Yr44mqUtlPszKxBr7VlhXgpVd8lFmWVRsXvh1ZsUDlF5GX8PgS6cbGKCefXF9+803f8SyWzeRZ1mNa7za4gbodrYslKPXl/tHtlDMynz+kj2n97J09eaXUiSLMtvXkJaaPKnVLHtLNXEBRNYi30GoWuGcHpXNs5dJu/wg6n8ypnLWXvTdOza3tmc8ov4tuONJZkgqZf6MK0Q+1rglOmeYBA9to1u+kdaflQnYyky9krZo+DrEm1958TNr0IFScGTxV8v9TgshKmXNXtahbNvNzWovnpQr2hnT7gJ1Suqmbk7pN5XZLK8KL8epqF74R3qcvuidDegdxbgXvPokFEk8cUSF7AYPNheyGG2ZqYOzF9tsleQ9nrRSJPPpOUlkOO4jAWpI3bg3S196c6lZq6rufuLYhnia/Jc3PYO4yZWzfuM2fEO5U+knO+MLk252/pMpFw59McVCTTHmXuPibfXbUiVpCql+2TvXJVGOULa3K+fAMraX6qSzdvw/FGcjaEVaJZGBktDF189N2+sNRzV+CZTvTdl37T0xuhWqZpFiytSWE+T4bvJmxd3+WZok/t4r8AFfaHiXZkyrwAAAAASUVORK5CYII=", 
    "noise.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA9QTFRFIiIiISEhIyMjHx8fICAgSEHIqwAAC3FJREFUeNrEWwmS4zgM46H/v3lFALKdtA9ZqdrZ2eruOLYOigTBw2bNI8zMPSyj/+gfmmf/4Ob9evRPFvUz+ufsV3FTfVV/pXm/1CJwc//Uf7V6pH7171tmv1i39a8yMVMN5uE1Pp7jF+59lOxP1MD9oYZF1dVW9wTurgvh2Vq/ln2KrK/rEQ2Me2p5juVn7UibqhtqkX1Ex68aAHtyzI4d9IVm7dxwvdaZfbC61P/Dx5qrj9Oidmu1in5nTeh9MRinFuv4xrDzaBQXpUmhjY2XxEq0/ck+ZotaRq2iL7rVOI0rq8my1YZbiRXDY5Kxijqd/kU6lu0YsGRUR4pJa/utpg5+dNyF1eDEuBivxTbtwrD5Einur7XWRHWxRsUTXXQ4r1IhCHZoAf7Qpg0D1x01UHA7UVfquZqjBaXKhfUffbONmolN9mkSCseF1+JKlRqVFDqBLZY61OJrV9gM5tOMdeyRLl0ZKoARjbpGlcWaalgM3+XeSqz4QY0MN4kREtGia9pmQ6JUuuRMVMw6lJCosWytpizEptW9SbA1IY6qnky7VffQXmvU3M2tydyitMKgcFmbd9hTUkFkkbiQPBx97kJodXDQX2mTUdW7DDMJK0Zb6daB8a32khST0X5SgqoVZP0ri6s11sKjbKJugUJCIJAu9Le23uoyBInvqNMewKnSgEbF0k8YpBGfiD51KoVwFFYJiDYfsHA+BN0pOQSUiVrNfVIJeBXajbOrcQsyaOWtbJGy9PqrCfwa4LKG7EKAygk76mMQZaIEkHi6JOhcZ0qFMbfzWAoxpu0Fplb3AtJKF1LncWrgCVk6BqSKDHG3zZoi4tTAC7biGmBcAKMjTYEz7Y8DQlus0BEYiVtLU7k2T6Injo9X5M/6uSYtEHae2iDRSUdNdYLZlIUBO2pi7pBgj2NygHN9gh3VzriJ2h1NBCdj0GfKnstqtDkidHnCOqc6VKgAdI2QTPMpmUPRqUZd9yCvhEXKkulPAqBS5+H8sgZKumPYEUEJqNPtsQZNInUOJUqTvuIeaiFNBMCBk2lE1+HGahfcUw0tadKTBCSZmx7D7HFctSg5f4nS4cxyc42FqWMGcAo4RsBuf6alnN4zytTTKXQBrkH83LI8s8GWB8JBQrVoK3SCspb+l7G4VI2jAWhr81yg88BLTIBqI6GwRyWhQ2o4ZqMsiONh44RfK95xzAXFkwU0I5isuMADC5p2gSEX6JnaOFE51smQbzt+SYbokxpvt3vKZzJWOk1qQyN/hBKcUT7fKR8M8ZJykhOSDjl8xvCOL7bVSMbqofKlMPLi6hCZwatA5WgUIXdA/5Yh39CVphWI4hTcpRF0qU98JykPkXHAPOAJHLl0ETSDdE80eVALKOdmrHc0+k6mvygwlphECXoxuOPSrBoXgjFAM+iLK1xJMekMEZtB6hRqkHEkhsEJiJg0Wm9NnIiCAEGUOmMb6DyW+Mq9FMErxQzqZStgLFVrSWdefGxz46UxJI7SpDqA3TAXuPGsrhyBwQQMACI/kosRTAChFYnW6gJPJglVEMJk1lPRyxe5GVzSBD+FMqkztWFHODXLLfZJ+eVWaJVGqic8u2BrPqh1Uj2CTjxpOLBJOM/3tDoOtPrC4fnB4fmNw2XcNEM+r7ZzRz5d5BOzH8iv7+TXOd4pXLQdLvwarhihQ8VJMMWcUsSSxPwcrhoW9kGWXGTJ5CCeyZo12jx2MnyI4oonsubiRjDl1UxNY8blVaZGCZUQr35i8feG9hEmTaYJ5OgLeZv4zQIAQK7NXwGAuH8IFsW6llIrg034nduNEVbCC/x1+zbI57sc12fQ37bTg/qD0kCfEiBjigxIZ6BIJhNJ41cF9jR3xtYMXhtDpood7jV9hVKHjRzdWPZ8GPAHFSfCgD+oGELFICMZ7A4ZAaRGsIwGo0Mupt8YDFv/et7phOKBXTaxy7K3c8V6QZNX+GTsig2HPuLEJsK0OdqRoUlkPhBI0emHvCSkMZXLajbCkgJ2HJkwN45+0nc/WUYy46eXsiq7YwNFu4qgbCp1QM/XfJHb0Vt8pz2SaUyJ2CjYGKYw9sS1n6c9btMutqddQCiecEPpBcRBf3LzoNLURMJ0yQqL46qc1gONhv2G/L7cOaznK5H5qm5ws7ZHTJW6vix3xF7uALSNVA3oS5JkUlfluuFPcHIZYi2xOfEG5Xo653sZv0+vpfRMudSLsHcSGFcrLTsBgD/zCpaSCZE2IN1HdjbodZzRBSkAoK38SxpTV4J4rnaEktJmMEbaWMMtUPkgOf3fDwDBDAw9XCrxjkp+5v2+clZwjbMOfk83vxA5U4iDD0bMiDwlcmpWY4GgwduDTE3x/6sQdIb/f6crlOp0AvV39vslP50urx34ae78dLCfp/qsjWwhnTNJtsONTwLWMeowRR3yWd+JN6UPGKsVMoJVbQWmZJIkVAhZYTQmRtOo0NJ+2Cc535uA7zZ2DxF3RVooICRWioQ5uJUvINgxceg/JLnaoOe/VO5WyLTtKQZBF/UwRJIY9Eajx4T5cKlJ5QlW8lS5JVwRFRtS9wY5ZI48ECsCm2i2FDllmGd3hO5g9Hq7wq2AmFtKCiIhaXSW0i5WCL7DlLuKivIBoCTtkK+4qYmsMOl+fiPcTAaQRp4bPODBIyB9uCj+7VuMZWKmIwBdL/vYPH5e5G8wTUt/Y86K5IpO5JoMUzKE7CajEVM0gv8ZDcH4c8n6d/60kuLOnT9hS/cFnofw5JD6nQ5PQuEJnaC4+sscwxYrrNAoU6wQAqHpvN6ZXn7BvHB/Wi8JNK/D26NC3bRqXEU4sUc4qTreevn2Gar+lm9t8BBLFjjOG2x8qsHnO2tXYMvWi+sGH1eDjyvJ/G+zZH5Odp2w81yYPpDd6fgmRbYbe0p+coa24gxN7lpK/IsR0hBWjZBSPjBcHrkahtJEdEP82Qf+i0iHVOdtB6SGNHJW1Tb+9gfN9sdd9Adl3PQnmfqT4M9zoUryESW9rJKwYLMdZGMT0ElvyDxJ2XtDQr0hTyTFBG7ixctlY/rHhbKxq/uTe1WD5yozXMmxtp3V+HNh8ClqeCgMnkUNOTp5MNj7aOxDsSajsQ+EiCNC2E2X10yXmfRODXxAYkHndZeZH7rMlGE/Ted5TNTk3nZ+4RiVThzdjFMteFdJiBUM870F0JnBuQeu28bKkRB+01hpAk6gx11FbqZwclGRuy2cuOp1Q76vz/2YlZk996s0MpX+h/hwBnq/eaXt0O/DOT40jl6nzlZKzq7UGZpMXrQB0+N8tSF/9zmrRBlUo2DfjMswtD3xXQe2+utK4WePzm99aNzfY8fcXeJutsZ9ZlGIOeO3gkksFEy2rgcI0yezm1fcbwm/tTusP992MH6dx0rPgek8mnT9b5/Ni3LoyksZivBteF+Y3fayAASIw5vqknubWw7RR7VOsIzw1AF534rB8nNjYlTpkpbi7YFivSPmCOrf3oFJRHupwX90bLbh6OArmnzFaHD9iZaulP5zT1gyS/aTGLeUpTX1WqKGlGFbI/GoMquPmYV33wLopfzkHjktlBtMBJfSnn5D7DxGWXk9yfYYxfSu1HLFJlaoVBPQ2Whnm8hJXx7wi1cRNs7jO+dpQ7DLOfuV7hdTZDYq5L90Fdv7ruLqpOCJjszty+TsZ7PEZI37o5HDlVqzrQ61UnvThfO+2ft+ICXqYrwxNv9q3Um+9vHVupN8bagjhWfwz3vJXhfdvrOIM+/kfWYRc88ixu07oVPx6tQ7eZ/xqo6X7MhftGSfqtZ9D/Mpt8qdW9H+n7kLI8oz7rRCJ00oHwSn/52PROyxK0O3y/chZ9IGtcoWzHwyX6hA7DptYEobqCx2ipa+o+X9OyAPJ3TKbkNozXfOliMCOuqXXcgfjppt5f+4m+4/AQYA7rFTSAXDOQYAAAAASUVORK5CYII=", 
    "page-icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFwmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAxOC0wNi0zMFQwMjowMToxNy0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjE2Mzg1YjEtMDhiNS1kMjRhLWI0MzItNzAzYjBmNDFjNjE5IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6YmNlMzU0ZTgtMDE3Zi1iMjRkLTg4MTYtOGZkZTZlYTgyZDg5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZTAzMzRhNzEtMmQ2Mi1lNDRjLWFiMjUtZGJjZTNlYTcwNTYwIiBzdEV2dDp3aGVuPSIyMDE4LTA2LTMwVDAyOjAxOjE3LTA1OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo2MTYzODViMS0wOGI1LWQyNGEtYjQzMi03MDNiMGY0MWM2MTkiIHN0RXZ0OndoZW49IjIwMTgtMDYtMzBUMDI6MDE6MTctMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+1Ie7qQAAADpJREFUOI1j/P//PwMlgIki3dQwgAWNT8g/jNRwAYolAx8GowZgpgOMeCbVAAYGwomJoAEkuWLgAxEAc7EGJRNwU4UAAAAASUVORK5CYII=", 
    "play-button.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSIjNDQ0IiBzdHJva2U9IiNlZWUiPg0KICAgIDxwYXRoIGQ9Ik0yNiAxNiBMIDggNCBMIDggMjggTCAyNiAxNiIvPg0KPC9zdmc+DQoNCg==", 
    "refresh-icon.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMHB4IiBoZWlnaHQ9IjIwcHgiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiBzdHlsZT0iZmlsbDogI2ZmZjsgZGlzcGxheTogYmxvY2s7Ij4NCiAgICAgICAgPHBhdGggZD0iTTQ3OS45NzEsMzIuMThjLTIxLjcyLDIxLjIxMS00Mi44OSw0My02NC41Miw2NC4zMDFjLTEuMDUsMS4yMy0yLjI2LTAuMTYtMy4wOS0wLjg1DQogICAgICAgICAgICAgICAgYy0yNC41MTEtMjMuOTgtNTQuNTgtNDIuMjgxLTg3LjIyMS01Mi44NGMtMzcuNi0xMi4xNi03OC40NDktMTQuMDctMTE3LjAyOS01LjU5Yy02OC42NywxNC42Ny0xMjguODExLDY0LjA1OS0xNTYuNDQsMTI4LjYwOQ0KICAgICAgICAgICAgICAgIGMwLjAzMSwwLjAxNCwwLjA2MiwwLjAyNSwwLjA5MywwLjAzOWMtMi4zLDQuNTM3LTMuNjA1LDkuNjY2LTMuNjA1LDE1LjFjMCwxOC40NzUsMTQuOTc3LDMzLjQ1MSwzMy40NTEsMzMuNDUxDQogICAgICAgICAgICAgICAgYzE1LjgzMSwwLDI5LjA4NC0xMS4wMDIsMzIuNTU1LTI1Ljc3M2MxOS43NTctNDEuOTc5LDU4LjgzMi03NC40NDUsMTAzLjk2Ny04NS41MjdjNTIuMi0xMy4xNywxMTEuMzcsMS4zMywxNDkuNCw0MC4wNDENCiAgICAgICAgICAgICAgICBjLTIyLjAzLDIxLjgzLTQ0LjM5MSw0My4zNC02Ni4zMyw2NS4yNmM1OS41Mi0wLjMyLDExOS4wNi0wLjE0MSwxNzguNTktMC4wOUM0ODAuMjkxLDE0OS42MTEsNDc5LjkzMSw5MC44OTEsNDc5Ljk3MSwzMi4xOHoiLz4NCiAgICAgICAgPHBhdGggZD0iTTQzMS42MDksMjk3LjVjLTE0LjYyLDAtMjcuMDQxLDkuMzgzLTMxLjU5MSwyMi40NTNjLTAuMDA5LTAuMDA0LTAuMDE5LTAuMDA4LTAuMDI3LTAuMDEyDQogICAgICAgICAgICAgICAgYy0xOS4xMSw0Mi41OS01Ny41Nyw3Ni4yMTktMTAyLjg0LDg4LjE4Yy01Mi43OTksMTQuMzExLTExMy40NSwwLjI5OS0xNTIuMTc5LTM5LjA1MWMyMS45Mi0yMS43Niw0NC4zNjktNDMuMDEsNjYuMTg5LTY0Ljg2OQ0KICAgICAgICAgICAgICAgIGMtNTkuNywwLjA0OS0xMTkuNDEsMC4wMjktMTc5LjExLDAuMDFjLTAuMTQsNTguNi0wLjE1OSwxMTcuMTg5LDAuMDExLDE3NS43ODljMjEuOTItMjEuOTEsNDMuNzUtNDMuOTEsNjUuNzktNjUuNjk5DQogICAgICAgICAgICAgICAgYzE0LjEwOSwxMy43ODksMjkuNzYsMjYuMDcsNDYuOTIsMzUuODY5YzU0LjczOSwzMS45NzEsMTIzLjM5OSwzOC42MDIsMTgzLjI5OSwxNy44OTENCiAgICAgICAgICAgICAgICBjNTcuNDc3LTE5LjI5NywxMDYuMDczLTYzLjE3OCwxMzEuMjEyLTExOC4zMThjMy42NDUtNS4zNTcsNS43NzYtMTEuODI0LDUuNzc2LTE4Ljc5M0M0NjUuMDYsMzEyLjQ3Nyw0NTAuMDgzLDI5Ny41LDQzMS42MDksMjk3LjUNCiAgICAgICAgICAgICAgICB6Ii8+DQo8L3N2Zz4NCg0K", 
    "regular_pixiv_icon.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAF62lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxOC0wNi0yMFQwMDo1NjoyOS0wNTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTgtMDYtMjBUMDE6MDA6MDEtMDU6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTgtMDYtMjBUMDE6MDA6MDEtMDU6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ZDA2Zjg3ZjUtMzY1MC1lMzRmLWFjY2UtZTFjYzY4OGVkMjY4IiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6ZGU4MTZkODItNmVkNi1mNTQxLTg3MTEtMGZlZTZkN2EwMWI5IiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6Y2YxNWJkY2QtOGJhZi0wMjQwLWI4NTktM2VjMGI0MDJlZjE1Ij4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjZjE1YmRjZC04YmFmLTAyNDAtYjg1OS0zZWMwYjQwMmVmMTUiIHN0RXZ0OndoZW49IjIwMTgtMDYtMjBUMDA6NTY6MjktMDU6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmQwNmY4N2Y1LTM2NTAtZTM0Zi1hY2NlLWUxY2M2ODhlZDI2OCIgc3RFdnQ6d2hlbj0iMjAxOC0wNi0yMFQwMTowMDowMS0wNTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7IMKLyAAAFYUlEQVR4nO3aWcgcRRAH8N9Oji/xTDzxQBMhAfXFCxVBxTx4IQQvRAQ1ICiKihJRUFBBQfFNUIyIgiLexgMUEVTwBI34oohK4n0gajzivbs+1Iy72ezMN7uzm/3G+IdhZ6d7aqqqq6uruqth5cN6sBVuwHnYtrexpvgNT+FifNvdkPR0PB8/4jL/HeFhPs7AN7gNjayhWwFP4A7M3pycbWY0cCHel8qeKeB8LJ8QU5PAUjxJKGBr3D5RdiaDk7AowZ029QVbCh5McMqkuZggDkowb9JcTBBztlTT/xdbvALGuea3uu6nU/QgfUeKUSogEyIRoec6vIxn8QG+x69oop32m4ftsATH4wQsxlRKb+zKaFj5cLvC+xmTTbyDG/ESfqjEE7vhElyA7Y1RGcMSzUb7IywTI3YIVqsmPGEdX+IqLBBR25qe744Mg06BlmDwLlwpEqci2nvjKJwoBFmAufgTP4mY/Gk8JxKVfvgQh2JXvIJ9jNAayiqgmf5eh5vxV06/+Tgd12MvwWieg2thPxGIZX7jIZGJru9D+xvhK07GYzp+pBLKeuf7hHA36C/87nhVOLl7dITPvpHY9Fu9z+fjbDGFnk//98Nq7ICfjWBKFCmghbXpx1boL/hWQvAvcHgXzWFHJnvvGPyS/vbD+pSvtTrWWemD3cjm+WnC5Iqc2tc4rIDWsJiV0nsBN+X0aQn+PlHBEnp9QFOM5v5iBIrQEOv4rGE/XhJXCMd5eU77EmERQ+1g9Y7aKuG5pxM+w8iXpT5IcKlwfnk8LK5CPMMyXNSn/YRhiY8QCR7Hwpz274SSBh6QRKzJi/FiT9tcEb7ePSjRMaElos083IoNgxLNtsQ+7nm+jZhXUyJgmQlIsCcOKOiz3IBWkODvnmdTYu98SljBTMPqgrYX8fsgxHqdYAOfCcFn4l5BgkUiQcrDKgNYQa+QD4gAYyYKn6GJcwrabzEA/90ddxenJ+Ne16uiIc4x8vAV/ihLLBHhbHZfBySmX/fXKTkNEpHTE5qrC+YpdtBvlyWUYBeR5q6oxtNmRUNx6PtWWUKzxZy/tipHE0CRBXxZlkhd5n0/NAraSqfIdVZAkadfUJZIXRXQFjtCeTiwLKG6KuBXkcTl4YiyhOqogBbem6bPUiVlq6MCiAOYPOykE9xNizoqoIFnCtpXGmAVqFtBVFOUu+WdSxClcKXzmbpZwCycVdB+kgELPuqkgKbYs/wtp72BRwwoU10U0MS7iqvZ7jbEDlYdfEBTbM4WBTcH4NxhiM90C2iKxGY3+fn9TgZIf3tRVQFzjPdw5Clx0Jq3rC0U+xhDF3lUUUAbV+soYBSKaKXXjzhYcQ3jErF7PVsFOapawE3C8RyJ13Ti81bXNR26+32OY0U2V2TWl4i6o8r7l6Nwgm1RuXGkWIp2EUflp4pR3FWEpnPS9kb6zl86VSKrRKVIUYZH+II3xAHJSFBVAVM2zsvbopLjyfTqRkNYXEN568iwEPeLc8qRFkxVIdQQh5KfisqOosMKQjlNcRJVRviGKLx6SyyDx6XPR7pyVSU2V5wn3CPOEteLRGW5MNdB5miCHdN3XxVHXG/qrP9jWbKrToGWmNsZthcjdZxOgdQG4a0/FxbzvZj7C8Uavif2SP/PtnHBZffvWDCOSDDpud82vRaVfK+2pbLTYUZGnTOSqc2J/xWgYp1dzdFM8PqkuZgg1iWiJmBLxYpE5NsPTZqTCWAtXsmc4JmiNqgXbflFy2wcuNQJG7AvnVWgLQKV3lrBBPfmEGmLkLVuK8lHIuT+k03r95eJGv41eFRsMV9TQOxo7Gzm1BLmoSmO044WGyn/ZrD/AKixCmbSPqIVAAAAAElFTkSuQmCC"
};
/* pako/lib/zlib/crc32.js, MIT license: https://github.com/nodeca/pako/ */
var crc32 = (function() {
    // Use ordinary array, since untyped makes no boost here
    function makeTable() {
        var c, table = [];

        for(var n =0; n < 256; n++){
            c = n;
            for(var k =0; k < 8; k++){
                c = ((c&1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1));
            }
            table[n] = c;
        }

        return table;
    }

    // Create table on load. Just 255 signed longs. Not a problem.
    var crcTable = makeTable();

    return function(buf) {
        var crc = 0;
        var t = crcTable, end = buf.length;

        crc = crc ^ (-1);

        for (var i = 0; i < end; i++ ) {
            crc = (crc >>> 8) ^ t[(crc ^ buf[i]) & 0xFF];
        }

        return (crc ^ (-1)); // >>> 0;
    };
})();

var helpers = {
    // Get and set values in localStorage.
    //
    // We don't use helpers.set_value/helpers.get_value since GreaseMonkey is inconsistent and changed
    // these functions unnecessarily.  We could polyfill those with this, but that would cause
    // the storage to change if those functions are restored.  Doing it this way also allows
    // us to share settings if a user switches from GM to TM.
    get_value: function(key, default_value)
    {
        key = "_ppixiv_" + key;

        if(!(key in localStorage))
            return default_value;

        var result = localStorage[key];
        return JSON.parse(result);
    },

    set_value: function(key, value)
    {
        key = "_ppixiv_" + key;

        var value = JSON.stringify(value);
        localStorage[key] = value;
    },

    // Preload an array of images.
    preload_images: function(images)
    {
        // We don't need to add the element to the document for the images to load, which means
        // we don't need to do a bunch of extra work to figure out when we can remove them.
        var preload = document.createElement("div");
        for(var i = 0; i < images.length; ++i)
        {
            var img = document.createElement("img");
            img.src = images[i];
            preload.appendChild(img);
        }
    },

    move_children: function(parent, new_parent)
    {
        for(var child = parent.firstChild; child; )
        {
            var next = child.nextSibling;
            new_parent.appendChild(child);
            child = next;
        }
    },
    
    remove_elements: function(parent)
    {
        for(var child = parent.firstChild; child; )
        {
            var next = child.nextElementSibling;
            parent.removeChild(child);
            child = next;
        }
    },

    create_style: function(css)
    {
        var style = document.createElement("style");
        style.textContent = css;
        return style;
    },

    create_from_template: function(type)
    {
        var template = document.body.querySelector(type);
        return template.firstElementChild.cloneNode(true);
    },

    // Fetch a simple data resource, and call callback with the result.
    //
    // In principle this is just a simple XHR.  However, if we make two requests for the same
    // resource before the first one finishes, browsers tend to be a little dumb and make a
    // whole separate request, instead of waiting for the first to finish and then just serving
    // the second out of cache.  This causes duplicate requests when prefetching video ZIPs.
    // This works around that problem by returning the existing XHR if one is already in progress.
    _fetches: {},
    fetch_resource: function(url, options)
    {
        if(this._fetches[url])
        {
            var request = this._fetches[url];

            // Remember that another fetch was made for this resource.
            request.fetch_count++;

            if(options != null)
                request.callers.push(options);

            return request;
        }

        var request = helpers.send_pixiv_request({
            "method": "GET",
            "url": url,
            "responseType": "arraybuffer",

            "headers": {
                "Accept": "application/json",
            },
            onload: function(data) {
                // Once the request finishes, future requests can be done normally and should be served
                // out of cache.
                delete helpers._fetches[url];

                // Call onloads.
                for(var options of request.callers.slice())
                {
                    try {
                        if(options.onload)
                            options.onload(data);
                    } catch(exc) {
                        console.error(exc);
                    }
                }
            },

            onerror: function(e) {
                console.error("Fetch failed");
                for(var options of request.callers.slice())
                {
                    try {
                        if(options.onerror)
                            options.onerror(e);
                    } catch(exc) {
                        console.error(exc);
                    }
                }
            },

            onprogress: function(e) {
                for(var options of request.callers.slice())
                {
                    try {
                        if(options.onprogress)
                            options.onprogress(e);
                    } catch(exc) {
                        console.error(exc);
                    }
                }
            },
        });        

        // Remember the number of times fetch_resource has been called on this URL.
        request.fetch_count = 1;
        request.callers = [];
        request.callers.push(options);

        this._fetches[url] = request;

        // Override request.abort to reference count fetching, so we only cancel the load if
        // every caller cancels.
        //
        // Note that this means you'll still receive events if the fetch isn't actually
        // cancelled, so you should unregister event listeners if that's important.
        var original_abort = request.abort;
        request.abort = function()
        {
            // Remove this caller's callbacks, if any.
            if(options != null)
            {
                var idx = request.callers.indexOf(options);
                if(idx != -1)
                    request.callers.splice(idx, 1);
            }
            
            if(request.fetch_count == 0)
            {
                console.error("Fetch was aborted more times than it was started:", url);
                return;
            }

            request.fetch_count--;
            if(request.fetch_count > 0)
                return;

            original_abort.call(request);
        };

        return request;
    },

    // For some reason, only the mode=manga page actually has URLs to each page.  Avoid
    // having to load an extra page by deriving it from the first page's URL, which looks
    // like:
    //
    // https://i.pximg.net/img-original/img/1234/12/12/12/12/12/12345678_p0.jpg
    //
    // Replace _p0 at the end with the page number.
    //
    // We can't tell the size of each image this way.
    get_url_for_page: function(illust_data, page, key)
    {
        var url = illust_data.urls[key];
        var match = /^(http.*)(_p)(0)(.*)/.exec(url);
        if(match == null)
        {
            console.error("Couldn't parse URL: " + url);
            return "";
        }
        return match[1] + match[2] + page.toString() + match[4];
    },

    // Prompt to save a blob to disk.  For some reason, the really basic FileSaver API disappeared from
    // the web.
    save_blob: function(blob, filename)
    {
        var blobUrl = URL.createObjectURL(blob);

        var a = document.createElement("a");
        a.hidden = true;
        document.body.appendChild(a);
        a.href = blobUrl;

        a.download = filename;
        
        a.click();

        // Clean up.
        //
        // If we revoke the URL now, or with a small timeout, Firefox sometimes just doesn't show
        // the save dialog, and there's no way to know when we can, so just use a large timeout.
        setTimeout(function() {
            window.URL.revokeObjectURL(blobUrl);
            a.parentNode.removeChild(a);
        }.bind(this), 1000);
    },

    // Return a Uint8Array containing a blank (black) image with the given dimensions and type.
    create_blank_image: function(image_type, width, height)
    {
        var canvas = document.createElement("canvas");
        canvas.width = width;
        canvas.height = height;

        var context = canvas.getContext('2d');
        context.clearRect(0, 0, canvas.width, canvas.height);

        var blank_frame = canvas.toDataURL(image_type, 1);
        if(!blank_frame.startsWith("data:" + image_type))
            throw "This browser doesn't support encoding " + image_type;

        var binary = atob(blank_frame.slice(13 + image_type.length));

        // This is completely stupid.  Why is there no good way to go from a data URL to an ArrayBuffer?
        var array = new Uint8Array(binary.length);
        for(var i = 0; i < binary.length; ++i)
            array[i] = binary.charCodeAt(i);
        return array;
    },

    fetch_ugoira_metadata: function(illust_id, callback)
    {
        var url = "/ajax/illust/" + illust_id + "/ugoira_meta";
        return helpers.get_request(url, {}, callback);
    },

    // Stop the underlying page from sending XHR requests, since we're not going to display any
    // of it and it's just unneeded traffic.  For some dumb reason, Pixiv sends error reports by
    // creating an image, instead of using a normal API.  Override window.Image too to stop it
    // from sending error messages for this script.
    //
    // Firefox is now also bad and seems to have removed beforescriptexecute.  The Web is not
    // much of a dependable platform.
    block_network_requests: function()
    {
        RealXMLHttpRequest = window.XMLHttpRequest;        
        window.Image = function() { };

        dummy_fetch = function() { };
        dummy_fetch.prototype.ok = true;
        dummy_fetch.prototype.sent = function() { return this; }
        window.fetch = function() { return new dummy_fetch(); }

        window.XMLHttpRequest = function() { }
    },

    // Stop all scripts from running on the page.  This only works in Firefox.  This is a basic
    // thing for a userscript to want to do, why can't you do it in Chrome?
    block_all_scripts: function()
    {
        window.addEventListener("beforescriptexecute", function(e) {
            e.stopPropagation();
            e.preventDefault();
        }, true);
    },

    add_style: function(css)
    {
        var head = document.getElementsByTagName('head')[0];

        var style = document.createElement('style');
        style.type = 'text/css';
        style.innerHTML = css;
        head.appendChild(style);
    },

    // Create a node from HTML.
    create_node: function(html)
    {
        var temp = document.createElement("div");
        temp.innerHTML = html;
        return temp.firstElementChild;
    },

    // Set or unset a class.
    set_class: function(element, className, enable)
    {
        if(element.classList.contains(className) == enable)
            return;

        if(enable)
            element.classList.add(className);
        else
            element.classList.remove(className);
    },

    age_to_string: function(seconds)
    {
        var to_plural = function(label, places, value)
        {
            var factor = Math.pow(10, places);
            var plural_value = Math.round(value * factor);
            if(plural_value > 1)
                label += "s";
            return value.toFixed(places) + " " + label;
        };
        if(seconds < 60)
            return to_plural("sec", 0, seconds);
        var minutes = seconds / 60;
        if(minutes < 60)
            return to_plural("min", 0, minutes);
        var hours = minutes / 60;
        if(hours < 24)
            return to_plural("hour", 0, hours);
        var days = hours / 24;
        if(days < 30)
            return to_plural("day", 0, days);
        var months = days / 30;
        if(months < 12)
            return to_plural("month", 0, months);
        var years = months / 12;
        return to_plural("year", 1, years);
    },

    get_extension: function(fn)
    {
        var parts = fn.split(".");
        return parts[parts.length-1];
    },

    encode_query: function(data) {
        var str = [];
        for (var key in data)
        {
            if(!data.hasOwnProperty(key))
                continue;
            str.push(encodeURIComponent(key) + "=" + encodeURIComponent(data[key]));
        }    
        return str.join("&");
    },

    // Sending requests in user scripts is a nightmare:
    // - In TamperMonkey you can simply use unsafeWindow.XMLHttpRequest.  However, in newer versions
    // of GreaseMonkey, the request will be sent, but event handlers (eg. load) will fail with a
    // permissions error.  (That doesn't make sense, since you can assign DOM events that way.)
    // - window.XMLHttpRequest will work, but won't make the request as the window, so it will
    // act like a cross-origin request.  We have to use GM_xmlHttpRequest/GM.XMLHttpRequest instead.
    // - But, we can't use that in TamperMonkey (at least in Chrome), since ArrayBuffer is incredibly
    // slow.  It seems to do its own slow buffer decoding: a 2 MB ArrayBuffer can take over half a
    // second to decode.  We need to use regular XHR with TamperMonkey.
    // - GM_xmlhttpRequest in GreaseMonkey doesn't send a referer by default, and we need to set it
    // manually.  (TamperMonkey does send a referer by default.)

    // send_request_gm: Send a request with GM_xmlhttpRequest.
    //
    // The returned object will have an abort method that might abort the request.
    // (TamperMonkey provides abort, but GreaseMonkey doesn't.)
    //
    // Only the following options are supported:
    //
    // - headers
    // - method
    // - data
    // - responseType
    // - onload
    // - onprogress
    //
    // The returned object will only have abort, which is a no-op in GM.
    //
    // onload will always be called (unless the request is aborted), so there's always just
    // one place to put cleanup handlers when a request finishes.
    //
    // onload will be called with only resp.response and not the full response object.  On
    // error, onload(null) will be called rather than onerror.
    //
    // We use a limited interface since we have two implementations of this, one using XHR (for TM)
    // and one using GM_xmlhttpRequest (for GM), and this prevents us from accidentally
    // using a field that's only implemented with GM_xmlhttpRequest and breaking TM.
    send_request_gm: function(user_options)
    {
        var options = {};
        for(var key of ["url", "headers", "method", "data", "responseType", "onload", "onprogress"])
        {
            if(!(key in user_options))
                continue;

            // We'll override onload.
            if(key == "onload")
            {
                options.real_onload = user_options.onload;
                continue;
            }
            options[key] = user_options[key];
        }

        // Set the referer, or some requests will fail.
        var url = new URL(document.location);
        url.hash = "";
        options.headers["Referer"] = url.toString();

        options.onload = function(response)
        {
            if(options.real_onload)
            {
                try {
                    options.real_onload(response.response);
                } catch(e) {
                    console.error(e);
                }
            }
        };

        // When is this ever called?
        options.onerror = function(response)
        {
            console.log("Request failed:", response);
            if(options.real_onload)
            {
                try {
                    options.real_onload(null);
                } catch(e) {
                    console.error(e);
                }
            }
        }        

        var actual_request = GM_xmlhttpRequest(options);

        return {
            abort: function()
            {
                // actual_request is null with newer, broken versions of GM, in which case
                // we only pretend to cancel the request.
                if(actual_request != null)
                    actual_request.abort();

                // Remove real_onload, so if we can't actually cancel the request, we still
                // won't call onload, since the caller is no longer expecting it.
                delete options.real_onload;
            },
        };
    },

    // The same as send_request_gm, but with XHR.
    send_request_xhr: function(options)
    {
        var xhr = new RealXMLHttpRequest();        
        xhr.open(options.method || "GET", options.url);

        if(options.headers)
        {
            for(var key in options.headers)
                xhr.setRequestHeader(key, options.headers[key]);
        }
        
        if(options.responseType)
            xhr.responseType = options.responseType;

        xhr.addEventListener("load", function(e) {
            if(options.onload)
            {
                try {
                    options.onload(xhr.response);
                } catch(exc) {
                    console.error(exc);
                }
            }
        });

        xhr.addEventListener("progress", function(e) {
            if(options.onprogress)
            {
                try {
                    options.onprogress(e);
                } catch(exc) {
                    console.error(exc);
                }
            }
        });
        
        if(options.method == "POST")
            xhr.send(options.data);
        else
            xhr.send();

        return {
            abort: function()
            {
                console.log("cancel");
                xhr.abort();
            },
        };
    },

    send_request: function(options)
    {
        // In GreaseMonkey, use send_request_gm.  Otherwise, use send_request_xhr.  If
        // GM_info.scriptHandler doesn't exist, assume we're in GreaseMonkey, since 
        // TamperMonkey always defines it.
        //
        // (e also assume that if GM_info doesn't exist we're in GreaseMonkey, since it's
        // GM that has a nasty habit of removing APIs that people are using, so if that
        // happens we're probably in GM.
        var greasemonkey = true;
        try
        {
            greasemonkey = GM_info.scriptHandler == null || GM_info.scriptHandler == "Greasemonkey";
        } catch(e) {
            greasemonkey = true;
        }

        if(greasemonkey)
            return helpers.send_request_gm(options);
        else
            return helpers.send_request_xhr(options);
    },

    // Send a request with the referer, cookie and CSRF token filled in.
    send_pixiv_request: function(options)
    {
        if(options.headers == null)
            options.headers = {};

        // Only set x-csrf-token for requests to www.pixiv.net.  It's only needed for API
        // calls (not things like ugoira ZIPs), and the request will fail if we're in XHR
        // mode and set headers, since it'll trigger CORS.
        var hostname = new URL(options.url, document.location).hostname;
        if(hostname == "www.pixiv.net")
            options.headers["x-csrf-token"] = global_data.csrf_token;

        return helpers.send_request(options);
    },

    // Why does Pixiv have 3 APIs?
    rpc_post_request: function(url, data, callback)
    {
        return helpers.send_pixiv_request({
            "method": "POST",
            "url": url,

            "data": helpers.encode_query(data),
            "responseType": "json",

            "headers": {
                "Accept": "application/json",
                "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
            },
            onload: function(data) {
                if(data && data.error)
                    console.error("Error in XHR request:", data.message)

                if(callback)
                    callback(data);
            },

            onerror: function(e) {
                console.error("Fetch failed");
                if(callback)
                    callback({"error": true, "message": "XHR error"});
            },
        });        
    },

    rpc_get_request: function(url, data, callback)
    {
        var params = new URLSearchParams();
        for(var key in data)
            params.set(key, data[key]);
        var query = params.toString();
        if(query != "")
            url += "?" + query;
        
        return helpers.send_pixiv_request({
            "method": "GET",
            "url": url,
            "responseType": "json",

            "headers": {
                "Accept": "application/json",
            },

            onload: function(data) {
                if(data && data.error)
                    console.error("Error in XHR request:", data.message)

                if(callback)
                    callback(data);
            },

            onerror: function(result) {
                console.error("Fetch failed");
                if(callback)
                    callback({"error": true, "message": "XHR error"});
            },
        });
    },

    post_request: function(url, data, callback)
    {
        return helpers.send_pixiv_request({
            "method": "POST",
            "url": url,
            "responseType": "json",

            "data" :JSON.stringify(data),

            "headers": {
                "Accept": "application/json",
                "Content-Type": "application/json; charset=utf-8",
            },
            onload: function(data) {
                if(data && data.error)
                    console.error("Error in XHR request:", data.message)

                if(callback)
                    callback(data);
            },

            onerror: function(e) {
                console.error("Fetch failed");
                if(callback)
                    callback({"error": true, "message": "XHR error"});
            },
        });        
    },

    get_request: function(url, data, callback)
    {
        var params = new URLSearchParams();
        for(var key in data)
            params.set(key, data[key]);
        var query = params.toString();
        if(query != "")
            url += "?" + query;

        return helpers.send_pixiv_request({
            "method": "GET",
            "url": url,
            "responseType": "json",

            "headers": {
                "Accept": "application/json",
            },
            onload: function(data) {
                if(data && data.error)
                    console.error("Error in XHR request:", data.message)

                if(callback)
                    callback(data);
            },

            onerror: function(e) {
                console.error("Fetch failed");
                if(callback)
                    callback({"error": true, "message": "XHR error"});
            },
        });        
    },

    // Download all URLs in the list.  Call callback with an array containing one ArrayData for each URL.  If
    // any URL fails to download, call callback with null.
    //
    // I'm not sure if it's due to a bug in the userscript extension or because we need to specify a
    // header here, but this doesn't properly use cache and reloads the resources from scratch, which
    // is really annoying.  We can't read the images directly since they're on a different domain.
    //
    // We could start multiple requests to pipeline this better.  However, the usual case where we'd download
    // lots of images is downloading a group of images, and in that case we're already preloading them as
    // images, so it's probably redundant to do it here.
    download_urls: function(urls, callback)
    {
        // Make a copy.
        urls = urls.slice(0);

        var results = [];
        var start_next = function()
        {
            var url = urls.shift();
            if(url == null)
            {
                callback(results);
                return;
            }

            // FIXME: This caches in GreaseMonkey, but not in TamperMonkey.  Do we need to specify cache
            // headers or is TamperMonkey just broken?
            GM_xmlhttpRequest({
                "method": "GET",
                "url": url,
                "responseType": "arraybuffer",

                "headers": {
                    "Cache-Control": "max-age=360000",
                },

                onload: function(result) {
                    results.push(result.response);
                    start_next();
                }.bind(this),
            });
        };

        start_next();
    },

    // Load a page in an iframe, and call callback on the resulting document.
    // Remove the iframe when the callback returns.
    load_data_in_iframe: function(url, callback)
    {
        if(GM_info.scriptHandler == "Tampermonkey")
        {
            // If we're in Tampermonkey, we don't need any of the iframe hijinks and we can
            // simply make a request with responseType: document.  This is much cleaner than
            // the Greasemonkey workaround below.
            helpers.send_pixiv_request({
                "method": "GET",
                "url": url,
                "responseType": "document",

                onload: function(data) {
                    callback(data);
                },
            });
            return;
        }

        // The above won't work with Greasemonkey.  It returns a document we can't access,
        // raising exceptions if we try to access it.  Greasemonkey's sandboxing needs to
        // be shot into the sun.
        //
        // Instead, we load the document in a sandboxed iframe.  It'll still load resources
        // that we don't need (though they'll mostly load from cache), but it won't run
        // scripts.
        var iframe = document.createElement("iframe");

        // Enable sandboxing, so scripts won't run in the iframe.  Set allow-same-origin, or
        // we won't be able to access it in contentDocument (which doesn't really make sense,
        // sandbox is for sandboxing the iframe, not us).
        iframe.sandbox = "allow-same-origin";
        iframe.src = url;
        iframe.hidden = true;
        document.body.appendChild(iframe);

        iframe.addEventListener("load", function(e) {
            try {
                callback(iframe.contentDocument);
            } catch(e) {
                // GM error logs don't make it to the console for some reason.
                console.error(e);
            } finally {
                // Remove the iframe.  For some reason, we have to do this after processing it.
                document.body.removeChild(iframe);
            }
        });
    },

    set_recent_bookmark_tags(tags)
    {
        helpers.set_value("recent-bookmark-tags", JSON.stringify(tags));
    },

    get_recent_bookmark_tags()
    {
        var recent_bookmark_tags = helpers.get_value("recent-bookmark-tags");
        if(recent_bookmark_tags == null)
            return [];
        return JSON.parse(recent_bookmark_tags);
    },

    // Move tag_list to the beginning of the recent tag list, and prune tags at the end.
    update_recent_bookmark_tags: function(tag_list)
    {
        // Move the tags we're using to the top of the recent bookmark tag list.
        var recent_bookmark_tags = helpers.get_recent_bookmark_tags();
        for(var i = 0; i < tag_list.length; ++i)
        {
            var tag = tag_list[i];
            var idx = recent_bookmark_tags.indexOf(tag_list[i]);
            if(idx != -1)
                recent_bookmark_tags.splice(idx, 1);
        }
        for(var i = 0; i < tag_list.length; ++i)
            recent_bookmark_tags.unshift(tag_list[i]);

        // Remove tags that haven't been used in a long time.
        recent_bookmark_tags.splice(20);
        helpers.set_recent_bookmark_tags(recent_bookmark_tags);
    },

    // Add tag to the recent search list, or move it to the front.
    add_recent_search_tag(tag)
    {
        var recent_tags = helpers.get_value("recent-tag-searches") || [];
        var idx = recent_tags.indexOf(tag);
        if(idx != -1)
            recent_tags.splice(idx, 1);
        recent_tags.unshift(tag);

        // Trim the list.
        recent_tags.splice(50);
        helpers.set_value("recent-tag-searches", recent_tags);

        window.dispatchEvent(new Event("recent-tag-searches-changed"));
    },

    remove_recent_search_tag(tag)
    {
        // Remove tag from the list.  There should normally only be one.
        var recent_tags = helpers.get_value("recent-tag-searches") || [];
        while(1)
        {
            var idx = recent_tags.indexOf(tag);
            if(idx == -1)
                break;
            recent_tags.splice(idx, 1);
        }
        helpers.set_value("recent-tag-searches", recent_tags);
        
        window.dispatchEvent(new Event("recent-tag-searches-changed"));
    },

    // Find globalInitData in a document, evaluate it and return it.  If it can't be
    // found, return null.
    get_global_init_data(doc)
    {
        // Find a script element that sets globalInitData.  This is the only thing in
        // the page that we use.
        var init_element;
        for(var element of doc.querySelectorAll("script"))
        {
            if(element.innerText == null || element.innerText.indexOf("globalInitData") == -1)
                continue;

            init_element = element
            break;
        }

        if(init_element == null)
            return null;
       
        // This script assigns globalInitData.  Wrap it in a function to return it.
        init_script = init_element.innerText;
        init_script = "(function() { " + init_script + "; return globalInitData; })();";

        var data = eval(init_script);

        // globalInitData is frozen, which we don't want.  Deep copy the object to undo this.
        data = JSON.parse(JSON.stringify(data))
        
        return data;
    },

    // If this is an older page (currently everything except illustrations), the CSRF token,
    // etc. are stored on an object called "pixiv".  We aren't actually executing scripts, so
    // find the script block.
    get_pixiv_data(doc)
    {
        // Find all script elements that set pixiv.xxx.  There are two of these, and we need
        // both of them.
        var init_elements = [];
        for(var element of doc.querySelectorAll("script"))
        {
            if(element.innerText == null)
                continue;
            if(!element.innerText.match(/pixiv.*(token|id) = /))
                continue;

            init_elements.push(element);
        }

        if(init_elements.length != 2)
            return null;
        
        // Create a stub around the scripts to let them execute as if they're initializing the
        // original object.
        var init_script = "";
        init_script += "(function() {";
        init_script += "var pixiv = { config: {}, context: {}, user: {} }; ";
        init_script += init_elements[0].innerText;
        init_script += init_elements[1].innerText;
        init_script += "return pixiv;";
        init_script += "})();";
        return eval(init_script);
    },

    get_tags_from_illust_data(illust_data)
    {
        // illust_data might contain a list of dictionaries (data.tags.tags[].tag), or
        // a simple list (data.tags[]), depending on the source.
        if(illust_data.tags.tags == null)
            return illust_data.tags;

        var result = [];
        for(var tag_data of illust_data.tags.tags)
            result.push(tag_data.tag);
            
        return result;
    },

    // Return true if the given illust_data.tags contains the pixel art (ドット絵) tag.
    tags_contain_dot(illust_data)
    {
        var tags = helpers.get_tags_from_illust_data(illust_data);
        for(var tag of tags)
            if(tag.indexOf("ドット") != -1)
                return true;

        return false;
    },

    fix_pixiv_links: function(root)
    {
        for(var a of root.querySelectorAll("A[target='_blank']"))
            a.target = "";

        for(var a of root.querySelectorAll("A[href*='jump.php']"))
        {
            a.relList.add("noreferrer");            
            var url = new URL(a.href);
            var target = url.search.substr(1); // remove ?
            target = decodeURIComponent(target);
            a.href = target;
        }
    },

    set_page_title: function(title)
    {
        document.querySelector("title").textContent = title;
    },

    set_page_icon: function(url)
    {
        document.querySelector("link[rel='icon']").href = url;
    },

    // Watch for clicks on links inside node.  If a search link is clicked, add it to the
    // recent search list.
    add_clicks_to_search_history: function(node)
    {
        node.addEventListener("click", function(e) {
            if(e.defaultPrevented)
                return;
            if(e.target.tagName != "A")
                return;

            var url = new URL(e.target.href);
            if(url.pathname != "/search.php")
                return;

            var tag = url.searchParams.get("word");
            console.log("Adding to tag search history:", tag);
            helpers.add_recent_search_tag(tag);
        });
    },

    // Add a basic event handler for an input:
    //
    // - When enter is pressed, submit will be called.
    // - Event propagation will be stopped, so global hotkeys don't trigger.
    //
    // Note that other event handlers on the input will still be called.
    input_handler: function(input, submit)
    {
        input.addEventListener("keydown", function(e) {
            // Always stopPropagation, so inputs aren't handled by main input handling.
            e.stopPropagation();

            if(e.keyCode == 13) // enter
                submit(e);
        });
    },
};

// This installs some minor tweaks that aren't related to the main viewer functionality.
(function() {
    // If this is an iframe, don't do anything.  This may be a helper iframe loaded by
    // load_data_in_iframe, in which case the main page will do the work.
    if(window.top != window.self)
        return;

    window.addEventListener("DOMContentLoaded", function(e) {
        try {
            if(window.location.pathname.startsWith("/bookmark.php"))
            {
                // On the follow list, make the user links point at the works page instead
                // of the useless profile page.
                var links = document.documentElement.querySelectorAll('A');
                for(var i = 0; i < links.length; ++i)
                {
                    var a = links[i];
                    a.href = a.href.replace(/member\.php/, "member_illust.php");
                }
            };
        } catch(e) {
            // GM error logs don't make it to the console for some reason.
            console.log(e);
        }
    });
})();

// Create an uncompressed ZIP from a list of files and filenames.
create_zip = function(filenames, files)
{
    if(filenames.length != files.length)
        throw "Mismatched array lengths";

    // Encode the filenames.
    var filename_blobs = [];
    for(var i = 0; i < filenames.length; ++i)
    {
        var filename = new Blob([filenames[i]]);
        filename_blobs.push(filename);
    }

    // Make CRC32s, and create blobs for each file.
    var blobs = [];
    var crc32s = [];
    for(var i = 0; i < filenames.length; ++i)
    {
        var data = files[i];
        var crc = crc32(new Int8Array(data));
        crc32s.push(crc);
        blobs.push(new Blob([data]));
    }

    var parts = [];
    var file_pos = 0;
    var file_offsets = [];
    for(var i = 0; i < filenames.length; ++i)
    {
        var filename = filename_blobs[i];
        var data = blobs[i];
        var crc = crc32s[i];

        // Remember the position of the local file header for this file.
        file_offsets.push(file_pos);

        var local_file_header = this.create_local_file_header(filename, data, crc);
        parts.push(local_file_header);
        file_pos += local_file_header.size;

        // Add the data.
        parts.push(data);
        file_pos += data.size;
    }

    // Create the central directory.
    var central_directory_pos = file_pos;
    var central_directory_size = 0;
    for(var i = 0; i < filenames.length; ++i)
    {
        var filename = filename_blobs[i];
        var data = blobs[i];
        var crc = crc32s[i];

        var file_offset = file_offsets[i];
        var central_record = this.create_central_directory_entry(filename, data, file_offset, crc);
        central_directory_size += central_record.size;
        parts.push(central_record);
    }

    var end_central_record = this.create_end_central(filenames.length, central_directory_pos, central_directory_size);
    parts.push(end_central_record);
    return new Blob(parts, {
        "type": "application/zip",
    });
};

create_zip.prototype.create_local_file_header = function(filename, file, crc)
{
    var data = struct("<IHHHHHIIIHH").pack(
        0x04034b50, // local file header signature
        10, // version needed to extract
        0, // general purpose bit flag
        0, // compression method
        0, // last mod file time
        0, // last mod file date
        crc, // crc-32
        file.size, // compressed size
        file.size, // uncompressed size
        filename.size, // file name length
        0 // extra field length
    );

    return new Blob([data, filename]);
};

create_zip.prototype.create_central_directory_entry = function(filename, file, file_offset, crc)
{
    var data = struct("<IHHHHHHIIIHHHHHII").pack(
        0x02014b50, // central file header signature
        10, // version made by
        10, // version needed to extract
        0, // general purpose bit flag
        0, // compression method
        0, // last mod file time
        0, // last mod file date
        crc,
        file.size, // compressed size
        file.size, // uncompressed size
        filename.size, // file name length
        0, // extra field length
        0, // file comment length
        0, // disk number start
        0, // internal file attributes
        0, // external file attributes
        file_offset // relative offset of local header
    );

    return new Blob([data, filename]);
}

create_zip.prototype.create_end_central = function(num_files, central_directory_pos, central_directory_size)
{
    var data = struct("<IHHHHIIH").pack(
        0x06054b50, // end of central dir signature
        0, // number of this disk
        0, // number of the disk with the start of the central directory
        num_files, // total number of entries in the central directory on this disk
        num_files, // total number of entries in the central directory
        central_directory_size, // size of the central directory
        central_directory_pos, // offset of start of central directory with respect to the starting disk number
        0 // .ZIP file comment length
    );
    return new Blob([data]);
} 
// A list of illustration IDs by page.
//
// Store the list of illustration IDs returned from a search, eg. bookmark.php?p=3,
// and allow looking up the next or previous ID for an illustration.  If we don't have
// data for the next or previous illustration, return the page that should be loaded
// to make it available.
//
// We can have gaps in the pages we've loaded, due to history navigation.  If you load
// page 1, then jump to page 3, we'll figure out that to get the illustration before the
// first one on page 3, we need to load page 2.
//
// One edge case is when the underlying search changes while we're viewing it.  For example,
// if we're viewing page 2 with ids [1,2,3,4,5], and when we load page 3 it has ids
// [5,6,7,8,9], that usually means new entries were added to the start since we started.
// We don't want the same ID to occur twice, so we'll detect if this happens, and clear
// all other pages.  That way, we'll reload the previous pages with the updated data if
// we navigate back to them.
class illust_id_list
{
    constructor()
    {
        this.illust_ids_by_page = {};
    };

    get_all_illust_ids()
    {
        // Make a list of all IDs we already have.
        var all_ids = [];
        for(var page of Object.keys(this.illust_ids_by_page))
        {
            var ids = this.illust_ids_by_page[page];
            all_ids = all_ids.concat(ids);
        }
        return all_ids;
    }

    get_highest_loaded_page()
    {
        var max_page = 1;
        for(var page of Object.keys(this.illust_ids_by_page))
            max_page = Math.max(max_page, page);
        return max_page;
    }

    // Add a page of results.
    //
    // If the page cache has been invalidated, return false.  This happens if we think the
    // results have changed too much for us to reconcile it.
    add_page(page, illust_ids)
    {
        if(this.illust_ids_by_page[page] != null)
        {
            console.warn("Page", page, "was already loaded");
            return true;
        }

        // Make a list of all IDs we already have.
        var all_illusts = this.get_all_illust_ids();

        // Special case: If there are any entries in this page which are also in the previous page,
        // just remove them from this page.
        //
        // For fast-moving pages like new_illust.php, we'll very often get a few entries at the
        // start of page 2 that were at the end of page 1 when we requested it, because new posts
        // have been added to page 1 that we haven't seen.  If we don't handle this, we'll clear
        // the page cache below on almost every page navigation.  Instead, we just remove the
        // duplicate IDs and end up with a slightly shorter page 2.
        var previous_page_illust_ids = this.illust_ids_by_page[page-1];
        if(previous_page_illust_ids)
        {
            var ids_to_remove = [];
            for(var new_id of illust_ids)
            {
                if(previous_page_illust_ids.indexOf(new_id) != -1)
                    ids_to_remove.push(new_id);
            }

            if(ids_to_remove.length > 0)
                console.log("Removing duplicate illustration IDs:", ids_to_remove.join(", "));
            illust_ids = illust_ids.slice();
            for(var new_id of ids_to_remove)
            {
                var idx = illust_ids.indexOf(new_id);
                illust_ids.splice(idx, 1);
            }
        }

        // If there's nothing on this page, don't add it, so this doesn't increase
        // get_highest_loaded_page().
        // FIXME: If we removed everything, the data source will appear to have reached the last
        // page and we won't load any more pages, since thumbnail_view assumes that a page not
        // returning any data means we're at the end.
        if(illust_ids.length == 0)
            return true;

        // See if we already have any IDs in illust_ids.
        var duplicated_id = false;
        for(var new_id of illust_ids)
        {
            if(all_illusts.indexOf(new_id) != -1)
            {
                duplicated_id = true;
                break;
            }
        }

        var result = true;
        if(duplicated_id)
        {
            console.info("Page", page, "duplicates an illustration ID.  Clearing page cache.");
            this.illust_ids_by_page = {};

            // Return false to let the caller know we've done this, and that it should clear
            // any page caches.
            result = false;
        }

        this.illust_ids_by_page[page] = illust_ids;
        return result;
    };

    // Return the page number illust_id is on, or null if we don't know.
    get_page_for_illust(illust_id)
    {
        for(var page of Object.keys(this.illust_ids_by_page))
        {
            var ids = this.illust_ids_by_page[page];
            page = parseInt(page);
            if(ids.indexOf(illust_id) != -1)
                return page;
        };
        return null;
    };

    // Return the next or previous illustration.  If we don't have that page, return null.
    get_neighboring_illust_id(illust_id, next)
    {
        var page = this.get_page_for_illust(illust_id);
        if(page == null)
            return null;

        var ids = this.illust_ids_by_page[page];
        var idx = ids.indexOf(illust_id);
        var new_idx = idx + (next? +1:-1);
        if(new_idx < 0)
        {
            // Return the last illustration on the previous page, or null if that page isn't loaded.
            var prev_page_no = page - 1;
            var prev_page_illust_ids = this.illust_ids_by_page[prev_page_no];
            if(prev_page_illust_ids == null)
                return null;
            return prev_page_illust_ids[prev_page_illust_ids.length-1];
        }
        else if(new_idx >= ids.length)
        {
            // Return the first illustration on the next page, or null if that page isn't loaded.
            var next_page_no = page + 1;
            var next_page_illust_ids = this.illust_ids_by_page[next_page_no];
            if(next_page_illust_ids == null)
                return null;
            return next_page_illust_ids[0];
        }
        else
        {
            return ids[new_idx];
        }
    };

    // Return the page we need to load to get the next or previous illustration.  This only
    // makes sense if get_neighboring_illust returns null.
    get_page_for_neighboring_illust(illust_id, next)
    {
        var page = this.get_page_for_illust(illust_id);
        if(page == null)
            return null;

        var ids = this.illust_ids_by_page[page];
        var idx = ids.indexOf(illust_id);
        var new_idx = idx + (next? +1:-1);
        if(new_idx >= 0 && new_idx < ids.length)
            return page;

        page += next? +1:-1;
        return page;
    };

    // Return the first ID, or null if we don't have any.
    get_first_id()
    {
        var keys = Object.keys(this.illust_ids_by_page);
        if(keys.length == 0)
            return null;

        var page = keys[0];
        return this.illust_ids_by_page[page][0];
    }

    // Return true if the given page is loaded.
    is_page_loaded(page)
    {
        return this.illust_ids_by_page[page] != null;
    }
};

// A data source asynchronously loads illust_ids to show.  The callback will be called
// with:
// {
//     'illust': {
//         illust_id1: illust_data1,
//         illust_id2: illust_data2,
//         ...
//     },
//     illust_ids: [illust_id1, illust_id2, ...]
//     next: function,
// }
//
// Some sources can retrieve user data, some can retrieve only illustration data, and
// some can't retrieve anything but IDs.
//
// The callback will always be called asynchronously, and data_source.callback can be set
// after creation.
//
// If "next" is included, it's a function that can be called to create a new data source
// to load the next page of data.  If there are no more pages, next will be null.

// A data source handles a particular source of images, depending on what page we're
// on:
//
// - Retrieves batches of image IDs to display, eg. a single page of bookmark results
// - Load another page of results with load_more()
// - Updates the page URL to reflect the current image
//
// Not all data sources have multiple pages.  For example, when we're viewing a regular
// illustration page, we get all of the author's other illust IDs at once, so we just
// load all of them as a single page.
class data_source
{
    constructor()
    {
        this.id_list = new illust_id_list();
        this.update_callbacks = [];
        this.loading_page_callbacks = {};
        this.first_empty_page = -1;
        this.update_callbacks = [];
    };

    // If a data source returns a name, we'll display any .data-source-specific elements in
    // the thumbnail view with that name.
    get name() { return null; }
    
    // Return the page that will be loaded by default, if load_page(null) is called.
    //
    // Most data sources store the page in the query.
    get_default_page()
    {
        var query_args = page_manager.singleton().get_query_args();
        return parseInt(query_args.get("p")) || 1;
    }

    // Load the given page, or the page of the current history state if page is null.
    // Call callback when the load finishes.
    //
    // If we synchronously know that the page doesn't exist, return false and don't
    // call callback.  Otherwise, return true.
    load_page(page, callback)
    {
        // If page is null, use the default page.
        if(page == null)
            page = this.get_default_page();

        // Check if we're trying to load backwards too far.
        if(page < 1)
        {
            console.info("No pages before page 1");
            return false;
        }

        // If we know there's no data on this page (eg. we loaded an earlier page before and it
        // was empty), don't try to load this one.  This prevents us from spamming empty page
        // requests.
        if(this.first_empty_page != -1 && page >= this.first_empty_page)
        {
            console.info("No pages after", this.first_empty_page);
            return false;
        }

        // If the page is already loaded, just call the callback.
        if(this.id_list.is_page_loaded(page))
        {
            setTimeout(function() {
                if(callback != null)
                    callback();
            }.bind(this), 0);
            return true;
        }
        
        // If a page is loading, loading_page_callbacks[page] is a list of callbacks waiting
        // for that page.
        if(this.loading_page_callbacks[page])
        {
            // This page is currently loading, so just add the callback to that page's list.
            // This makes sure we don't spam the same request several times if different things
            // request it at the same time.
            if(callback != null)
                this.loading_page_callbacks[page].push(callback);
            return true;
        }

        // Create the callbacks list for this page if it doesn't exist.  This also records that
        // the request for this page is in progress.
        if(this.loading_page_callbacks[page] == null)
            this.loading_page_callbacks[page] = [];

        // Add this callback to the list, if any.
        if(callback != null)
            this.loading_page_callbacks[page].push(callback);

        var is_synchronous = true;

        var completed = function()
        {
            // If there were no results, then we've loaded the last page.  Don't try to load
            // any pages beyond this.
            if(this.id_list.illust_ids_by_page[page] == null)
            {
                console.log("No data on page", page);
                if(this.first_empty_page == -1 || page < this.first_empty_page)
                    this.first_empty_page = page;
            };

            // Call all callbacks waiting for this page.
            var callbacks = this.loading_page_callbacks[page].slice();
            delete this.loading_page_callbacks[page];

            for(var callback of callbacks)
            {
                try {
                    callback();
                } catch(e) {
                    console.error(e);
                }
            }
        }.bind(this);

        // Start the actual load.
        var result = this.load_page_internal(page, function() {
            // If is_synchronous is true, the data source finished immediately before load_page_internal
            // returned.  This happens when the data is already available and didn't need to be loaded.
            // Make sure we complete the load asynchronously even if it finished synchronously.
            if(is_synchronous)
                setTimeout(completed, 0);
            else
                completed();
        }.bind(this));

        is_synchronous = false;

        if(!result)
        {
            // No request was actually started, so we're not calling the callback.
            delete this.loading_page_callbacks[page];
        }

        return result;
    }

    // Return the illust_id to display by default.
    //
    // This should only be called after the initial data is loaded.
    get_default_illust_id()
    {
        // If we have an explicit illust_id in the hash, use it.  Note that some pages (in
        // particular illustration pages) put this in the query, which is handled in the particular
        // data source.
        var hash_args = page_manager.singleton().get_hash_args();
        if(hash_args.has("illust_id"))
            return hash_args.get("illust_id");
        
        return this.id_list.get_first_id();
    };

    // Return the page title to use.
    get page_title()
    {
        return "Pixiv";
    }

    // This is implemented by the subclass.
    load_page_internal(page, callback)
    {
        return false;
    }

    // This is called when the currently displayed illust_id changes.  The illust_id should
    // always have been loaded by this data source, so it should be in id_list.  The data
    // source should update the history state to reflect the current state.
    //
    // If add_to_history, use history.pushState, otherwise use history.replaceState.  replace
    // is true when we're just updating the current state (eg. after loading the first image)
    // and false if we're actually navigating to a new image that should have a new history
    // entry (eg. pressing page down).
    set_current_illust_id(illust_id, add_to_history)
    {
    };

    // Load from the current history state.  Load the current page (if needed), then call
    // callback().
    //
    // This is called when changing history states.  The data source should load the new
    // page if needed, then call this.callback.
    load_from_current_state(callback)
    {
        this.load_page(null, callback);
    };

    // Return the estimated number of items per page.  This is used to pad the thumbnail
    // list to reduce items moving around when we load pages.
    get estimated_items_per_page()
    {
        return 10;
    };

    // Return true if this data source wants to show thumbnails by default, or false if
    // the default image should be shown.
    get show_thumbs_by_default()
    {
        return true;
    };

    // If we're viewing a page specific to a user (an illustration or artist page), return
    // the user ID we're viewing.  This can change when refreshing the UI.
    get viewing_user_id()
    {
        return null;
    };

    // If we're viewing a page specific to a user (an illustration or artist page), return
    // the username we're viewing.  This can change when refreshing the UI.
    get viewing_username()
    {
        return null;
    };
    // Add or remove an update listener.  These are called when the data source has new data,
    // or wants a UI refresh to happen.
    add_update_listener(callback)
    {
        this.update_callbacks.push(callback);
    }

    remove_update_listener(callback)
    {
        var idx = this.update_callbacks.indexOf(callback);
        if(idx != -1)
            this.update_callbacks.splice(idx);
    }

    // Register a page of data.
    add_page(page, illust_ids)
    {
        var result = this.id_list.add_page(page, illust_ids);

        // Call update listeners asynchronously to let them know we have more data.
        setTimeout(function() {
            this.call_update_listeners();
        }.bind(this), 0);
        return result;
    }

    call_update_listeners()
    {
        var callbacks = this.update_callbacks.slice();
        for(var callback of callbacks)
        {
            try {
                callback();
            } catch(e) {
                console.error(e);
            }
        }
        
    }

    // Each data source can have a different UI in the thumbnail view.  container is
    // the thumbnail-ui-box container to refresh.
    refresh_thumbnail_ui(container) { }

    // A helper for setting up UI links.  Find the link with the given data-type,
    // set all {key: value} entries as query parameters, and remove any query parameters
    // where value is null.  Set .selected if the resulting URL matches the current one.
    //
    // If default_values is present, it tells us the default key that will be used if
    // a key isn't present.  For example, search.php?s_mode=s_tag is the same as omitting
    // s_mode.  We prefer to omit it rather than clutter the URL with defaults, but we
    // need to know this to figure out whether an item is selected or not.
    set_item(container, type, fields, default_values)
    {
        var link = container.querySelector("[data-type='" + type + "']");
        if(link == null)
        {
            console.warn("Couldn't find button with selector", type);
            return;
        }

        // This button is selected if all of the keys it sets are present in the URL.
        var button_is_selected = true;

        // Adjust the URL for this button.
        var url = new URL(document.location);
        var new_url = new URL(document.location);
        for(var key of Object.keys(fields))
        {
            var value = fields[key];
            if(value != null)
                new_url.searchParams.set(key, value);
            else
                new_url.searchParams.delete(key);

            var this_value = value;
            if(this_value == null && default_values != null)
                this_value = default_values[key];

            var selected_value = url.searchParams.get(key);
            if(selected_value == null && default_values != null)
                selected_value = default_values[key];

            if(this_value != selected_value)
                button_is_selected = false;
        }

        helpers.set_class(link, "selected", button_is_selected);

        link.href = new_url.toString();
    };

    // Highlight search menu popups if any entry other than the default in them is
    // selected.
    //
    // selector_list is a list of selectors for each menu item.  If any of them are
    // selected and don't have the data-default attribute, set .active on the popup.
    // Search filters 
    // Set the active class on all top-level dropdowns which have something other than
    // the default selected.
    set_active_popup_highlight(container, selector_list)
    {
        for(var popup of selector_list)
        {
            var box = container.querySelector(popup);
            var selected_item = box.querySelector(".selected");
            if(selected_item == null)
            {
                // There's no selected item.  If there's no default item then this is normal, but if
                // there's a default item, it should have been selected by default, so this is probably
                // a bug.
                var default_entry_exists = box.querySelector("[data-default]") != null;
                if(default_entry_exists)
                    console.warn("Popup", popup, "has no selection");
                continue;
            }

            var selected_default = selected_item.dataset["default"];
            helpers.set_class(box, "active", !selected_default);
        }
    }
};

// /discovery
//
// This is an actual API call for once, so we don't need to scrape HTML.  We only show
// recommended works (we don't have a user view list).
//
// The API call returns 1000 entries.  We don't do pagination, we just show the 1000 entries
// and then stop.  I haven't checked to see if the API supports returning further pages.
class data_source_discovery extends data_source
{
    get name() { return "discovery"; }
    
    load_page_internal(page, callback)
    {
        if(page != 1)
            return false;

        // Get "mode" from the URL.  If it's not present, use "all".
        var query_args = page_manager.singleton().get_query_args();
        var mode = query_args.get("mode") || "all";
        
        var data = {
            type: "illust",
            sample_illusts: "auto",
            num_recommendations: 1000,
            page: "discovery",
            mode: mode,
        };

        helpers.get_request("/rpc/recommender.php", data, function(result) {
            // Unlike other APIs, this one returns IDs as ints rather than strings.  Convert back
            // to strings.
            var illust_ids = [];
            for(var illust_id of result.recommendations)
                illust_ids.push(illust_id + "");

            // Register the new page of data.
            this.add_page(page, illust_ids);

            if(callback)
                callback();
        }.bind(this))

        return true;
    };

    // This doesn't matter for this data source, since we don't load any more pages after the first.
    get estimated_items_per_page() { return 1; }

    get page_title() { return "Discovery"; }
    get_displaying_text() { return "Recommended Works"; }

    // Update the address bar with the current illustration ID.  If that illust ID is on a different
    // page and we know the page number, update that as well.
    set_current_illust_id(illust_id, add_to_history)
    {
        var query_args = page_manager.singleton().get_query_args();
        var hash_args = page_manager.singleton().get_hash_args();

        // Store the current illust ID in the hash, since the real bookmark page doesn't have
        // an illust_id.
        hash_args.set("illust_id", illust_id);

        page_manager.singleton().set_args(query_args, hash_args, add_to_history);
    };

    refresh_thumbnail_ui(container)
    {
        // Set .selected on the current mode.
        var current_mode = new URL(document.location).searchParams.get("mode") || "all";
        helpers.set_class(container.querySelector(".box-link[data-type=all]"), "selected", current_mode == "all");
        helpers.set_class(container.querySelector(".box-link[data-type=safe]"), "selected", current_mode == "safe");
        helpers.set_class(container.querySelector(".box-link[data-type=r18]"), "selected", current_mode == "r18");
    }
}


// bookmark_detail.php
//
// We use this as an anchor page for viewing recommended illusts for an image, since
// there's no dedicated page for this.
class data_source_related_illusts extends data_source
{
    get name() { return "related-illusts"; }
   
    load_page(page, callback)
    {
        // The first time we load a page, get info about the source illustration too, so
        // we can show it in the UI.
        if(!this.fetched_illust_info)
        {
            this.fetched_illust_info = true;

            var query_args = page_manager.singleton().get_query_args();
            var illust_id = query_args.get("illust_id");
            image_data.singleton().get_image_info(illust_id, function(illust_info) {
                this.illust_info = illust_info;
                this.call_update_listeners();
            }.bind(this));
        }

        return super.load_page(page, callback);
    }
     
    load_page_internal(page, callback)
    {
        if(page != 1)
            return false;

        var query_args = page_manager.singleton().get_query_args();
        var illust_id = query_args.get("illust_id");

        var data = {
            type: "illust",
            sample_illusts: illust_id,
            num_recommendations: 1000,
        };

        helpers.get_request("/rpc/recommender.php", data, function(result) {
            // Unlike other APIs, this one returns IDs as ints rather than strings.  Convert back
            // to strings.
            var illust_ids = [];
            for(var illust_id of result.recommendations)
                illust_ids.push(illust_id + "");

            // Register the new page of data.
            this.add_page(page, illust_ids);

            if(callback)
                callback();
        }.bind(this))

        return true;
    };

    // This doesn't matter for this data source, since we don't load any more pages after the first.
    get estimated_items_per_page() { return 1; }

    get page_title() { return "Related Illusts"; }
    get_displaying_text() { return "Related Illustrations"; }

    // Update the address bar with the current illustration ID.  If that illust ID is on a different
    // page and we know the page number, update that as well.
    set_current_illust_id(illust_id, add_to_history)
    {
        var query_args = page_manager.singleton().get_query_args();
        var hash_args = page_manager.singleton().get_hash_args();

        // Store the current illust ID in the hash.  This is the image being viewed, not the source
        // image for the suggestion list (which is in the query).
        hash_args.set("illust_id", illust_id);

        page_manager.singleton().set_args(query_args, hash_args, add_to_history);
    };

    refresh_thumbnail_ui(container)
    {
        // Set the source image.
        var source_link = container.querySelector(".image-for-suggestions");
        source_link.hidden = this.illust_info == null;
        if(this.illust_info)
        {
            source_link.href = "/member_illust.php?illust_id=" + this.illust_info.illustId + "#ppixiv";

            var img = source_link.querySelector(".image-for-suggestions > img");
            img.src = this.illust_info.urls.thumb;
        }
    }
}

// /ranking.php
//
// This one has an API, and also formats the first page of results into the page.
// They have completely different formats, and the page is updated dynamically (unlike
// the pages we scrape), so we ignore the page for this one and just use the API.
//
// An exception is that we load the previous and next days from the page.  This is better
// than using our current date, since it makes sure we have the same view of time as
// the search results.
class data_source_rankings extends data_source
{
    constructor(doc)
    {
        super();

        this.doc = doc;
        this.max_page = 999999;

        // This is the date that the page is showing us.
        // We want to know the date the page is showing us, even if we requested the
        // default.  This is a little tricky since there's no unique class on that element,
        // but it's always the element after "before" and the element before "after".
        //
        // We can also get this from the API response, but doing it here reduces UI
        // pop by filling it in at the start.
        var current = doc.querySelector(".ranking-menu .before + li > a");
        this.today_text = current? current.innerText:"";

        // Figure out today
        var after = doc.querySelector(".ranking-menu .after > a");
        if(after)
            this.prev_date = new URL(after.href).searchParams.get("date");

        var before = doc.querySelector(".ranking-menu .before > a");
        if(before)
            this.next_date = new URL(before.href).searchParams.get("date");
    }
    
    get name() { return "rankings"; }
   
    load_page_internal(page, callback)
    {
        if(page > this.max_page)
            return false;

        // Get "mode" from the URL.  If it's not present, use "all".
        var query_args = page_manager.singleton().get_query_args();
        
        var data = {
            format: "json",
            p: page,
        };

        var date = query_args.get("date");
        if(date)
            data.date = date;

        var content = query_args.get("content");
        if(content)
            data.content = content;

        var mode = query_args.get("mode");
        if(mode)
            data.mode = mode;

        helpers.get_request("/ranking.php", data, function(result) {
            // If "next" is false, this is the last page.
            if(!result.next)
                this.max_page = Math.min(page, this.max_page);

            /* if(this.today_text == null)
                this.today_text = result.date;
            if(this.prev_date == null && result.prev_date)
                this.prev_date = result.prev_date;
            if(this.next_date == null && result.next_date)
                this.next_date = result.next_date; */
        
            // This returns a struct of data that's like the thumbnails data response,
            // but it's not quite the same.
            var illust_ids = [];
            for(var item of result.contents)
            {
                // Most APIs return IDs as strings, but this one returns them as ints.
                // Convert them to strings.
                var illust_id = "" + item.illust_id;
                var user_id = "" + item.user_id;
                illust_ids.push(illust_id);
                image_data.singleton().set_user_id_for_illust_id(illust_id, user_id)
            }
        
            // Register the new page of data.
            this.add_page(page, illust_ids);

            if(callback)
                callback();
        }.bind(this))

        return true;
    };

    get estimated_items_per_page() { return 50; }

    get page_title() { return "Rankings"; }
    get_displaying_text() { return "Rankings"; }

    // Update the address bar with the current illustration ID.  If that illust ID is on a different
    // page and we know the page number, update that as well.
    set_current_illust_id(illust_id, add_to_history)
    {
        var query_args = page_manager.singleton().get_query_args();
        var hash_args = page_manager.singleton().get_hash_args();

        // Store the current illust ID in the hash, since the real bookmark page doesn't have
        // an illust_id.
        hash_args.set("illust_id", illust_id);

        page_manager.singleton().set_args(query_args, hash_args, add_to_history);
    };

    refresh_thumbnail_ui(container)
    {
        var query_args = page_manager.singleton().get_query_args();
        
        this.set_item(container, "content-all", {content: null});
        this.set_item(container, "content-illust", {content: "illust"});
        this.set_item(container, "content-ugoira", {content: "ugoira"});
        this.set_item(container, "content-manga", {content: "manga"});

        this.set_item(container, "mode-daily", {mode: null}, {mode: "daily"});
        this.set_item(container, "mode-daily-r18", {mode: "daily_r18"});
        this.set_item(container, "mode-weekly", {mode: "weekly"});
        this.set_item(container, "mode-monthly", {mode: "monthly"});
        this.set_item(container, "mode-rookie", {mode: "rookie"});
        this.set_item(container, "mode-male", {mode: "male"});
        this.set_item(container, "mode-female", {mode: "female"});

        if(this.today_text)
            container.querySelector(".nav-today").innerText = this.today_text;

        var yesterday = container.querySelector(".nav-yesterday");
        yesterday.hidden = this.prev_date == null;
        if(this.prev_date)
        {
            var url = new URL(window.location);
            url.searchParams.set("date", this.prev_date);
            yesterday.querySelector("a").href = url;
        }

        var tomorrow = container.querySelector(".nav-tomorrow");
        tomorrow.hidden = this.next_date == null;
        if(this.next_date)
        {
            var url = new URL(window.location);
            url.searchParams.set("date", this.next_date);
            tomorrow.querySelector("a").href = url;
        }

        // Not all combinations of content and mode exist.  For example, there's no ugoira
        // monthly, and we'll get an error page if we load it.  Hide navigations that aren't
        // available.  This isn't perfect: if you want to choose ugoira when you're on monthly
        // you need to select a different time range first.  We could have the content links
        // switch to daily if not available...
        var available_combinations = [
            "all/daily",
            "all/daily_r18",
            "all/weekly",
            "all/monthly",
            "all/rookie",
            "all/male",
            "all/female",

            "illust/daily",
            "illust/daily_r18",
            "illust/weekly",
            "illust/monthly",
            "illust/rookie",

            "ugoira/daily",
            "ugoira/weekly",
            "ugoira/daily_r18",

            "manga/daily",
            "manga/daily_r18",
            "manga/weekly",
            "manga/monthly",
            "manga/rookie",
        ];

        // Check each link in both checked-links sections.
        for(var a of container.querySelectorAll(".checked-links a"))
        {
            var url = new URL(a.href, document.location);
            var link_content = url.searchParams.get("content") || "all";
            var link_mode = url.searchParams.get("mode") || "daily";
            var name = link_content + "/" + link_mode;

            var available = available_combinations.indexOf(name) != -1;

            var is_content_link = a.dataset.type.startsWith("content");
            if(is_content_link)
            {
                // If this is a content link (eg. illustrations) and the combination of the
                // current time range and this content type isn't available, make this link
                // go to daily rather than hiding it, so all content types are always available
                // and you don't have to switch time ranges just to select a different type.
                if(!available)
                {
                    url.searchParams.delete("mode");
                    a.href = url;
                }
            }
            else
            {
                // If this is a mode link (eg. weekly) and it's not available, just hide
                // the link.
                a.hidden = !available;
            }
        }
    }
}

// This is a base class for data sources that work by loading a regular Pixiv page
// and scraping it.
//
// This wouldn't be needed if we could access the mobile APIs, but for some reason those
// use different authentication tokens and can't be accessed from the website.
//
// All of these work the same way.  We keep the current URL (ignoring the hash) synced up
// as a valid page URL that we can load.  If we change pages or other search options, we
// modify the URL appropriately.
class data_source_from_page extends data_source
{
    // The constructor receives the original HTMLDocument.
    constructor(doc)
    {
        super();

        this.original_doc = doc;
        this.items_per_page = 1;

        // Remember the URL that original_doc came from.
        if(doc != null)
            this.original_url = document.location.toString();
    }

    // Return true if the two URLs refer to the same data.
    is_same_page(url1, url2)
    {
        var cleanup_url = function(url)
        {
            var url = new URL(url);

            // p=1 and no page at all is the same.  Remove p=1 so they compare the same.
            if(url.searchParams.get("p") == "1")
                url.searchParams.delete("p");

            // Any "x" parameter is a dummy that we set to force the iframe to load, so ignore
            // it here.
            url.searchParams.delete("x");

            // The hash doesn't affect the page that we load.
            url.hash = "";
            return url.toString();
        };

        var url1 = cleanup_url(url1);
        var url2 = cleanup_url(url2);
        return url1 == url2;
    }

    load_page_internal(page, callback)
    {
        // Our page URL looks like eg.
        //
        // https://www.pixiv.net/bookmark.php?p=2
        //
        // possibly with other search options.  Request the current URL page data.
        var url = new unsafeWindow.URL(document.location);

        // Update the URL with the current page.
        var params = url.searchParams;
        params.set("p", page);

        if(this.original_url && this.is_same_page(url, this.original_url))
        {
            this.finished_loading_illust(page, this.original_doc, callback);
            return true;
        }

        // Work around a browser issue: loading an iframe with the same URL as the current page doesn't
        // work.  (This might have made sense once upon a time when it would always recurse, but today
        // this doesn't make sense.)  Just add a dummy query to the URL to make sure it's different.
        //
        // This usually doesn't happen, since we'll normally use this.original_doc if we're reading
        // the same page.  Skip it if it's not needed, so we don't throw weird URLs at the site if
        // we don't have to.
        if(this.is_same_page(url, document.location.toString()))
            params.set("x", 1);
                
        url.search = params.toString();

        console.log("Loading:", url.toString());

        helpers.load_data_in_iframe(url.toString(), function(document) {
            this.finished_loading_illust(page, document, callback);
        }.bind(this));
        return true;
    };

    get estimated_items_per_page() { return this.items_per_page; }

    // We finished loading a page.  Parse it, register the results and call the completion callback.
    finished_loading_illust(page, document, callback)
    {
        var illust_ids = this.parse_document(document);
        if(illust_ids == null)
        {
            // The most common case of there being no data in the document is loading
            // a deleted illustration.  See if we can find an error message.
            console.error("No data on page");
            var error = document.querySelector(".error-message");
            var error_message = "Error loading page";
            if(error != null)
                error_message = error.textContent;
            message_widget.singleton.show(error_message);
            message_widget.singleton.clear_timer();
            return;
        }

        // Assume that if the first request returns 10 items, all future pages will too.  This
        // is usually correct unless we happen to load the last page last.  Allow this to increase
        // in case that happens.  (This is only used by the thumbnail view.)
        if(this.items_per_page == 1)
            this.items_per_page = Math.max(illust_ids.length, this.items_per_page);

        // Register the new page of data.
        if(!this.add_page(page, illust_ids))
        {
            // The page list was cleared because the underlying results have changed too much,
            // which means we want to re-request pages when they're viewed next.  Clear original_doc,
            // or we won't actually do that for page 1.
            this.original_doc = null;
            this.original_url = null;
        }

        if(callback)
            callback();
    }

    // Parse the loaded document and return the illust_ids.
    parse_document(document)
    {
        throw "Not implemented";
    }

    // Update the address bar with the current illustration ID.  If that illust ID is on a different
    // page and we know the page number, update that as well.
    set_current_illust_id(illust_id, add_to_history)
    {
        var query_args = page_manager.singleton().get_query_args();
        var hash_args = page_manager.singleton().get_hash_args();

        // Store the current illust ID in the hash, since the real bookmark page doesn't have
        // an illust_id.
        hash_args.set("illust_id", illust_id);

        // Update the current page.  (This can be undefined if we're on a page that isn't
        // actually loaded for some reason.)
        var original_page = this.id_list.get_page_for_illust(illust_id);
        if(original_page != null)
            query_args.set("p", original_page);

        page_manager.singleton().set_args(query_args, hash_args, add_to_history);
    };
};

// There are two ways we can show images for a user: from an illustration page
// (member_illust.php?mode=medium&illust_id=1234), or from the user's works page
// (member_illust.php?id=1234).
//
// The illustration page is better, since it gives us the ID of every post by the
// user, so we don't have to fetch them page by page, but we have to know the ID
// of a post to get to to that.  It's also handy because we can tell where we are
// in the list from the illustration ID without having to know which page we're on,
// the page has the user info encoded (so we don't have to request it separately,
// making loads faster), and if we're going to display a specific illustration, we
// don't need to request it separately either.
//
// However, we can only do searching and filtering on the user page, and that's
// where we land when we load a link to the user.
class data_source_artist extends data_source
{
    get name() { return "artist"; }
  
    get viewing_user_id()
    {
        var query_args = page_manager.singleton().get_query_args();
        return query_args.get("id");
    };

    // If we're viewing a page specific to a user (an illustration or artist page), return
    // the username we're viewing.  This can change when refreshing the UI.
    get viewing_username()
    {
        return this.username;
    };
    
    load_page_internal(page, callback)
    {
        if(page != 1)
            return false;

        this.post_tags = [];
        
        // Make sure the user info is loaded.  This should normally be preloaded by globalInitData
        // in main.js, and this won't make a request.
        image_data.singleton().get_user_info(this.viewing_user_id, function(user_info) {
            console.log("... continue");
            this.user_info = user_info;
            this.call_update_listeners();

            helpers.get_request("/ajax/user/" + this.viewing_user_id + "/profile/all", {}, function(result) {
                var illust_ids = [];
                for(var illust_id in result.body.illusts)
                    illust_ids.push(illust_id);
                for(var illust_id in result.body.manga)
                    illust_ids.push(illust_id);

                // Sort the two sets of IDs back together, putting higher (newer) IDs first.
                illust_ids.sort(function(lhs, rhs)
                {
                    return parseInt(rhs) - parseInt(lhs);
                });

                // Register the new page of data.
                this.add_page(page, illust_ids);

                if(callback)
                    callback();

                // Request common tags for these posts.
                //
                // get_request doesn't handle PHP's wonky array format for GET arguments, so we just
                // format it here.
                this.post_tags = [];
                var tags_for_illust_ids = illust_ids.slice(0,50);
                var id_args = "";
                for(var id of tags_for_illust_ids)
                {
                    if(id_args != "")
                        id_args += "&";
                    id_args += "ids%5B%5D=" + id;
                }
                helpers.get_request("/ajax/tags/frequent/illust?" + id_args, {}, function(result) {
                    for(var tag of result.body)
                        this.post_tags.push(tag);
                    this.call_update_listeners();
                }.bind(this));
            }.bind(this));
        }.bind(this));        

        return true;
    };

    refresh_thumbnail_ui(container, thumbnail_view)
    {
        if(this.user_info)
        {
            thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
            helpers.set_page_icon(this.user_info.isFollowed? binary_data['favorited_icon.png']:binary_data['regular_pixiv_icon.png']);
        }

        this.set_item(container, "works", {type: null});
        this.set_item(container, "manga", {type: "manga"});
        this.set_item(container, "ugoira", {type: "ugoira"});

        // Refresh the post tag list.
        var current_query = new URL(document.location).searchParams.toString();
        
        var tag_list = container.querySelector(".post-tag-list");
        helpers.remove_elements(tag_list);
        
        var add_tag_link = function(tag)
        {
            var a = document.createElement("a");
            a.classList.add("box-link");
            a.classList.add("following-tag");
            a.innerText = tag;

            var url = new URL(document.location);

            if(tag != "All")
                url.searchParams.set("tag", tag);
            else
            {
                url.searchParams.delete("tag");
                a.dataset["default"] = 1;
            }

            a.href = url.toString();
            if(url.searchParams.toString() == current_query)
                a.classList.add("selected");
            tag_list.appendChild(a);
        };

        add_tag_link("All");
        for(var tag of this.post_tags || [])
            add_tag_link(tag);

        this.set_active_popup_highlight(container, [".member-tags-box"]);
    }

    get page_title()
    {
        if(this.user_info)
            return this.user_info.name;
        else
            return "Loading...";
    }

    get_displaying_text()
    {
        if(this.user_info)
            return this.user_info.name + "'s illustrations";
        else
            return "Illustrations";
    };
}

class data_source_current_illust extends data_source_from_page
{
    get name() { return "illust"; }

    // Show the illustration by default.
    get show_thumbs_by_default()
    {
        return false;
    };

    get_default_page() { return 1; }

    // We only have one page and we already have it when we're constructed, but we wait to load
    // it until load_page is called so this acts the same as the asynchronous data sources.
    load_page(page, callback)
    {
        // This data source only ever loads a single page.
        if(page != null && page != 1)
            return false;

        return super.load_page(page, callback);
    }

    parse_document(document)
    {
        var data = helpers.get_global_init_data(document);
        if(data == null)
        {
            console.error("Couldn't find globalInitData");
            return;
        }

        var illust_id = Object.keys(data.preload.illust)[0];
        var user_id = Object.keys(data.preload.user)[0];
        this.user_info = data.preload.user[user_id];
        var this_illust_data = data.preload.illust[illust_id];

        // XXX: This is done in main.js, so we don't need to do it here.
        // Add the precache data for the image and user.
        image_data.singleton().add_illust_data(this_illust_data);
        image_data.singleton().add_user_data(data.preload.user[user_id]);

        // Stash the user data so we can use it in get_displaying_text.
        this.user_info = data.preload.user[user_id];

        // Add the image list.
        var illust_ids = [];
        for(var related_illust_id in this_illust_data.userIllusts)
        {
            if(related_illust_id == illust_id)
                continue;
            illust_ids.push(related_illust_id);
        }

        // Make sure our illustration is in the list.
        if(illust_ids.indexOf(illust_id) == -1)
            illust_ids.push(illust_id);

        // Sort newest first.
        illust_ids.sort(function(a,b) { return b-a; });
        
        return illust_ids;
    };

    // Unlike most data_source_from_page implementations, we only have a single page.
    get_default_illust_id()
    {
        // ?illust_id should always be an illustration ID on illustration pages.
        var query_args = page_manager.singleton().get_query_args();
        return query_args.get("illust_id");
    };
 
    set_current_illust_id(illust_id, replace)
    {
        var query_args = page_manager.singleton().get_query_args();
        var hash_args = page_manager.singleton().get_hash_args();

        query_args.set("illust_id", illust_id);

        page_manager.singleton().set_args(query_args, hash_args, replace);
    };

    get page_title()
    {
        if(this.user_info)
            return this.user_info.name;
        else
            return "Illustrations";
    }

    get_displaying_text()
    {
        if(this.user_info)
            return this.user_info.name + "'s illustrations";
        else
            return "Illustrations";
    };

    refresh_thumbnail_ui(container, thumbnail_view)
    {
        if(this.user_info)
        {
            thumbnail_view.avatar_widget.set_from_user_data(this.user_info);
            helpers.set_page_icon(this.user_info.isFollowed? binary_data['favorited_icon.png']:binary_data['regular_pixiv_icon.png']);
        }
    }

    get page_title()
    {
        if(this.user_info)
            return this.user_info.name;
        else
            return "Illustrations";
    }

    get viewing_user_id()
    {
        if(this.user_info == null)
            return null;
        return this.user_info.userId;
    };
    
    get viewing_username()
    {
        if(this.user_info == null)
            return null;
        return this.user_info.name;
    }
};

// bookmark.php
//
// If id is in the query, we're viewing another user's bookmarks.  Otherwise, we're
// viewing our own.
class data_source_bookmarks extends data_source_from_page
{
    get name() { return "bookmarks"; }
    
    constructor(doc)
    {
        super(doc);
        this.bookmark_tags = [];
    }

    // Return true if we're viewing our own bookmarks.
    viewing_own_bookmarks()
    {
        var query_args = page_manager.singleton().get_query_args();
        return !query_args.has("id");
    }

    // Parse the loaded document and return the illust_ids.
    parse_document(document)
    {
        var title = document.querySelector(".user-name[title]");
        this.username = title.getAttribute("title");

        // Grab the user's bookmark tags, if any.
        this.bookmark_tags = [];
        for(var element of document.querySelectorAll("#bookmark_list a[href*='bookmark.php']"))
        {
            var tag = new URL(element.href).searchParams.get("tag");
            if(tag != null)
                this.bookmark_tags.push(tag);
        }

        var items = document.querySelectorAll("._image-items .image-item");

        var user_data = { };
        var illust_ids = [];

        for(var i = 0; i < items.length; ++i)
        {
            var item = items[i];

            // Pull the illustration ID out of the link.  For some reason, URLSearchParams
            // is stupid and can't handle being given a .search that has ? on it.  
            var link = item.querySelector("a[href^='member_illust']");
            var user_data_div = item.querySelector("[data-user_id]");

            // If user_data_div doesn't exist, skip the entry even if we have a link.  This happens
            // for deleted entries.
            if(user_data_div == null)
                continue;

            var query = new URL(link.href).search.substr(1);
            var params = new URLSearchParams(query);
            var illust_id = params.get("illust_id");
            illust_ids.push(illust_id);
        }

        return illust_ids;
    }

    get page_title()
    {
        if(!this.viewing_own_bookmarks())
        {
            if(this.username)
                return this.viewing_username + "'s Bookmarks";
            return "User's Bookmarks";
        }

        return "Bookmarks";
    }

    get_displaying_text()
    {
        if(!this.viewing_own_bookmarks())
        {
            if(this.viewing_username)
                return this.viewing_username + "'s Bookmarks";
            return "User's Bookmarks";
        }

        var query_args = page_manager.singleton().get_query_args();

        var private_bookmarks = query_args.get("rest") == "hide";
        var displaying = private_bookmarks? "Private bookmarks":"Bookmarks";

        var tag = query_args.get("tag");
        if(tag)
            displaying += " with tag \"" + tag + "\"";

        return displaying;
    };

    refresh_thumbnail_ui(container)
    {
        // The public/private button only makes sense when viewing your own bookmarks.
        container.querySelector(".bookmarks-public-private").hidden = !this.viewing_own_bookmarks();

        // Set up the public and private buttons.
        this.set_item(container, "public", {rest: null});
        this.set_item(container, "private", {rest: "hide"});

        // Refresh the bookmark tag list.
        var current_query = new URL(document.location).searchParams.toString();

        var tag_list = container.querySelector(".bookmark-tag-list");
        
        helpers.remove_elements(tag_list);

        var add_tag_link = function(tag)
        {
            var a = document.createElement("a");
            a.classList.add("box-link");
            a.classList.add("following-tag");
            a.innerText = tag;

            var url = new URL(document.location);
            if(tag == "Uncategorized")
                url.searchParams.set("untagged", 1);
            else
                url.searchParams.delete("untagged", 1);

            if(tag != "All" && tag != "Uncategorized")
                url.searchParams.set("tag", tag);
            else
                url.searchParams.delete("tag");

            a.href = url.toString();
            if(url.searchParams.toString() == current_query)
                a.classList.add("selected");
            tag_list.appendChild(a);
        };

        add_tag_link("All");
        add_tag_link("Uncategorized");
        for(var tag of this.bookmark_tags || [])
            add_tag_link(tag);
    }

    get viewing_user_id()
    {
        var query_args = page_manager.singleton().get_query_args();
        return query_args.get("id");
    };
    
    get viewing_username()
    {
        return this.username;
    }
};

// new_illust.php
class data_source_new_illust extends data_source_from_page
{
    get name() { return "new_illust"; }

    // Parse the loaded document and return the illust_ids.
    parse_document(document)
    {
        var items = document.querySelectorAll("A.work[href*='member_illust.php']");

        var illust_ids = [];
        for(var item of items)
        {
            var url = new URL(item.href);
            illust_ids.push(url.searchParams.get("illust_id"));
        }
        return illust_ids;
    }

    get page_title()
    {
        return "New Works";
    }

    get_displaying_text()
    {
        return "New Works";
    };

    refresh_thumbnail_ui(container)
    {
        this.set_item(container, "new-illust-type-all", {type: null});
        this.set_item(container, "new-illust-type-illust", {type: "illust"});
        this.set_item(container, "new-illust-type-manga", {type: "manga"});
        this.set_item(container, "new-illust-type-ugoira", {type: "ugoira"});

        // These links are different from anything else on the site: they switch between
        // two top-level pages, even though they're just flags and everything else is the
        // same.
        var all_ages_link = container.querySelector("[data-type='new-illust-ages-all']");
        var r18_link = container.querySelector("[data-type='new-illust-ages-r18']");

        var button_is_selected = true;

        var url = new URL(document.location);
        url.pathname = "/new_illust.php";
        all_ages_link.href = url;

        var url = new URL(document.location);
        url.pathname = "/new_illust_r18.php";
        r18_link.href = url;

        var url = new URL(document.location);
        var currently_all_ages = url.pathname == "/new_illust.php";
        helpers.set_class(currently_all_ages? all_ages_link:r18_link, "selected", button_is_selected);
    }
}

// bookmark_new_illust.php
class data_source_bookmarks_new_illust extends data_source_from_page
{
    get name() { return "bookmarks_new_illust"; }

    constructor(doc)
    {
        super(doc);
        this.bookmark_tags = [];
    }

    // Parse the loaded document and return the illust_ids.
    parse_document(document)
    {
        this.bookmark_tags = [];
        for(var element of document.querySelectorAll(".menu-items a[href*='bookmark_new_illust.php?tag'] span.icon-text"))
            this.bookmark_tags.push(element.innerText);
        
        var element = document.querySelector("#js-mount-point-latest-following");
        var items = JSON.parse(element.dataset.items);

        var illust_ids = [];
        for(var illust of items)
            illust_ids.push(illust.illustId);

        return illust_ids;
    }

    get page_title()
    {
        return "Following";
    }

    get_displaying_text()
    {
        return "Following";
    };

    refresh_thumbnail_ui(container)
    {
        // Refresh the bookmark tag list.
        var current_tag = new URL(document.location).searchParams.get("tag") || "All";

        var tag_list = container.querySelector(".bookmark-tag-list");
        helpers.remove_elements(tag_list);

        var add_tag_link = function(tag)
        {
            var a = document.createElement("a");
            a.classList.add("box-link");
            a.classList.add("following-tag");
            a.innerText = tag;

            var url = new URL(document.location);
            if(tag != "All")
                url.searchParams.set("tag", tag);
            else
                url.searchParams.delete("tag");

            a.href = url.toString();
            if(tag == current_tag)
                a.classList.add("selected");
            tag_list.appendChild(a);
        };

        add_tag_link("All");
        for(var tag of this.bookmark_tags)
            add_tag_link(tag);
    }
};

// search.php
class data_source_search extends data_source_from_page
{
    get name() { return "search"; }

    parse_document(document)
    {
        // The actual results are encoded in a string for some reason.
        var result_list_json = document.querySelector("#js-mount-point-search-result-list").dataset.items;
        var illusts = JSON.parse(result_list_json);

        // Store related tags.  Only do this the first time and don't change it when we read
        // future pages, so the tags don't keep changing as you scroll around.
        if(this.related_tags == null)
        {
            var related_tags_json = document.querySelector("#js-mount-point-search-result-list").dataset.relatedTags;
            var related_tags = JSON.parse(related_tags_json);
            this.related_tags = related_tags;
        }

        if(this.tag_translation == null)
        {
            var span = document.querySelector(".search-result-information .translation-column-title");
            if(span != null)
            {
                this.tag_translation = span.innerText;
                console.log(this.tag_translation);
            }
        }
        
        var illust_ids = [];
        for(var illust of illusts)
            illust_ids.push(illust.illustId);

        return illust_ids;
    }

    get page_title()
    {
        var query_args = page_manager.singleton().get_query_args();

        var displaying = "Search: ";
        var tag = query_args.get("word");
        if(tag)
            displaying += tag;
        
        return displaying;
    }

    get_displaying_text()
    {
        var displaying = this.page_title;

        // Add the tag translation if there is one.  We only put this in the page and not
        // the title to avoid cluttering the title.
        if(this.tag_translation != null)
            displaying += " (" + this.tag_translation + ")";
        
        return displaying;
    };

    refresh_thumbnail_ui(container, thumbnail_view)
    {
        if(this.related_tags)
        {
            thumbnail_view.tag_widget.set({
                tags: this.related_tags
            });
        }

        this.set_item(container, "ages-all", {mode: null});
        this.set_item(container, "ages-safe", {mode: "safe"});
        this.set_item(container, "ages-r18", {mode: "r18"});

        this.set_item(container, "order-newest", {order: null}, {order: "date_d"});
        this.set_item(container, "order-oldest", {order: "date"});
        this.set_item(container, "order-male", {order: "popular_male_d"});
        this.set_item(container, "order-female", {order: "popular_female_d"});

        this.set_item(container, "search-type-all", {type: null});
        this.set_item(container, "search-type-illust", {type: "illust"});
        this.set_item(container, "search-type-manga", {type: "manga"});
        this.set_item(container, "search-type-ugoira", {type: "ugoira"});

        this.set_item(container, "search-all", {s_mode: null}, {s_mode: "s_tag"});
        this.set_item(container, "search-exact", {s_mode: "s_tag_full"});
        this.set_item(container, "search-text", {s_mode: "s_tc"});

        this.set_item(container, "res-all", {wlt: null, hlt: null, wgt: null, hgt: null});
        this.set_item(container, "res-high", {wlt: 3000, hlt: 3000, wgt: null, hgt: null});
        this.set_item(container, "res-medium", {wlt: 1000, hlt: 1000, wgt: 2999, hgt: 2999});
        this.set_item(container, "res-low", {wlt: null, hlt: null, wgt: 999, hgt: 999});

        this.set_item(container, "aspect-ratio-all", {ratio: null});
        this.set_item(container, "aspect-ratio-landscape", {ratio: "0.5"});
        this.set_item(container, "aspect-ratio-portrait", {ratio: "-0.5"});
        this.set_item(container, "aspect-ratio-square", {ratio: "0"});
       
        this.set_item(container, "bookmarks-all", {blt: null, bgt: null});
        this.set_item(container, "bookmarks-5000", {blt: 5000, bgt: null});
        this.set_item(container, "bookmarks-2500", {blt: 2500, bgt: null});
        this.set_item(container, "bookmarks-1000", {blt: 1000, bgt: null});
        this.set_item(container, "bookmarks-500", {blt: 500, bgt: null});
        this.set_item(container, "bookmarks-250", {blt: 250, bgt: null});
        this.set_item(container, "bookmarks-100", {blt: 100, bgt: null});

        // The time filter is a range, but I'm not sure what time zone it filters in
        // (presumably either JST or UTC).  There's also only a date and not a time,
        // which means you can't actually filter "today", since there's no way to specify
        // which "today" you mean.  So, we offer filtering starting at "this week",
        // and you can just use the default date sort if you want to see new posts.
        // For "this week", we set the end date a day in the future to make sure we
        // don't filter out posts today.
        this.set_item(container, "time-all", {scd: null, ecd: null});

        var format_date = function(date)
        {
            var f = (date.getYear() + 1900).toFixed();
            return (date.getYear() + 1900).toFixed().padStart(2, "0") + "-" +
                    (date.getMonth() + 1).toFixed().padStart(2, "0") + "-" +
                    date.getDate().toFixed().padStart(2, "0");
        };

        var set_date_filter = function(name, start, end)
        {
            var start_date = format_date(start);
            var end_date = format_date(end);
            this.set_item(container, name, {scd: start_date, ecd: end_date});
        }.bind(this);

        var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
        var last_week = new Date(); last_week.setDate(last_week.getDate() - 7);
        var last_month = new Date(); last_month.setMonth(last_month.getMonth() - 1);
        var last_year = new Date(); last_year.setFullYear(last_year.getFullYear() - 1);
        set_date_filter("time-week", last_week, tomorrow);
        set_date_filter("time-month", last_month, tomorrow);
        set_date_filter("time-year", last_year, tomorrow);
        for(var years_ago = 1; years_ago <= 7; ++years_ago)
        {
            var start_year = new Date(); start_year.setFullYear(start_year.getFullYear() - years_ago - 1);
            var end_year = new Date(); end_year.setFullYear(end_year.getFullYear() - years_ago);
            set_date_filter("time-years-ago-" + years_ago, start_year, end_year);
        }

        this.set_active_popup_highlight(container, [".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box"]);

        // The "reset search" button removes everything in the query except search terms.
        var box = container.querySelector(".reset-search");
        var url = new URL(document.location);
        var tag = url.searchParams.get("word");
        url.search = "";
        if(tag != null)
            url.searchParams.set("word", tag);
        box.href = url;
     }
 };

// This is a simple hack to piece together an MJPEG MKV from a bunch of JPEGs.

var encode_mkv = (function() {
    var encode_length = function(value)
    {
        // Encode a 40-bit EBML int.  This lets us encode 32-bit ints with no extra logic.
        return struct(">BI").pack(0x08, value);
    };

    var header_int = function(container, identifier, value)
    {
        container.push(new Uint8Array(identifier));
        var data = struct(">II").pack(0, value);
        var size = data.byteLength;
        container.push(encode_length(size));
        container.push(data);
    };

    var header_float = function(container, identifier, value)
    {
        container.push(new Uint8Array(identifier));
        var data = struct(">f").pack(value);
        var size = data.byteLength;
        container.push(encode_length(size));
        container.push(data);
    };

    var header_data = function(container, identifier, data)
    {
        container.push(new Uint8Array(identifier));
        container.push(encode_length(data.byteLength));
        container.push(data);
    };

    // Return the total size of an array of ArrayBuffers.
    var total_size = function(array)
    {
        var size = 0;
        for(var idx = 0; idx < array.length; ++idx)
        {
            var item = array[idx];
            size += item.byteLength;
        }
        return size;
    };

    var append_array = function(a1, a2)
    {
        var result = new Uint8Array(a1.byteLength + a2.byteLength);
        result.set(new Uint8Array(a1));
        result.set(new Uint8Array(a2), a1.byteLength);
        return result;
    };

    // Create an EBML block from an identifier and a list of Uint8Array parts.  Return a
    // single Uint8Array.
    var create_data_block = function(identifier, parts)
    {
        var identifier = new Uint8Array(identifier);
        var data_size = total_size(parts);
        var encoded_data_size = encode_length(data_size);
        var result = new Uint8Array(identifier.byteLength + encoded_data_size.byteLength + data_size);
        var pos = 0;

        result.set(new Uint8Array(identifier), pos);
        pos += identifier.byteLength;

        result.set(new Uint8Array(encoded_data_size), pos);
        pos += encoded_data_size.byteLength;

        for(var i = 0; i < parts.length; ++i)
        {
            var part = parts[i];
            result.set(new Uint8Array(part), pos);
            pos += part.byteLength;
        }

        return result;
    };

    // EBML data types
    var ebml_header = function()
    {
        var parts = [];
        header_int(parts, [0x42, 0x86], 1); // EBMLVersion
        header_int(parts, [0x42, 0xF7], 1); // EBMLReadVersion
        header_int(parts, [0x42, 0xF2], 4); // EBMLMaxIDLength
        header_int(parts, [0x42, 0xF3], 8); // EBMLMaxSizeLength
        header_data(parts, [0x42, 0x82], new Uint8Array([0x6D, 0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61])); // DocType ("matroska")
        header_int(parts, [0x42, 0x87], 4); // DocTypeVersion
        header_int(parts, [0x42, 0x85], 2); // DocTypeReadVersion
        return create_data_block([0x1A, 0x45, 0xDF, 0xA3], parts); // EBML
    };

    var ebml_info = function(duration)
    {
        var parts = [];
        header_int(parts, [0x2A, 0xD7, 0xB1], 1000000); // TimecodeScale
        header_data(parts, [0x4D, 0x80], new Uint8Array([120])); // MuxingApp ("x") (this shouldn't be mandatory)
        header_data(parts, [0x57, 0x41], new Uint8Array([120])); // WritingApp ("x") (this shouldn't be mandatory)
        header_float(parts, [0x44, 0x89], duration * 1000); // Duration (why is this a float?)
        return create_data_block([0x15, 0x49, 0xA9, 0x66], parts); // Info
    };

    var ebml_track_entry_video = function(width, height)
    {
        var parts = [];
        header_int(parts, [0xB0], width); // PixelWidth
        header_int(parts, [0xBA], height); // PixelHeight
        return create_data_block([0xE0], parts); // Video
    };

    var ebml_track_entry = function(width, height)
    {
        var parts = [];
        header_int(parts, [0xD7], 1); // TrackNumber
        header_int(parts, [0x73, 0xC5], 1); // TrackUID
        header_int(parts, [0x83], 1); // TrackType (video)
        header_int(parts, [0x9C], 0); // FlagLacing
        header_int(parts, [0x23, 0xE3, 0x83], 33333333); // DefaultDuration (overridden per frame)
        header_data(parts, [0x86], new Uint8Array([0x56, 0x5f, 0x4d, 0x4a, 0x50, 0x45, 0x47])); // CodecID ("V_MJPEG")
        parts.push(ebml_track_entry_video(width, height));
        return create_data_block([0xAE], parts); // TrackEntry
    };

    var ebml_tracks = function(width, height)
    {
        var parts = [];
        parts.push(ebml_track_entry(width, height));
        return create_data_block([0x16, 0x54, 0xAE, 0x6B], parts); // Tracks
    };

    var ebml_simpleblock = function(frame_data)
    {
        // We should be able to use encode_length(1), but for some reason, while everything else
        // handles our non-optimal-length ints just fine, this field doesn't.  Manually encode it
        // instead.
        var result = new Uint8Array([
            0x81, // track number 1 (EBML encoded)
            0, 0, // timecode relative to cluster
            0x80, // flags (keyframe)
        ]); 

        result = append_array(result, frame_data);
        return result;
    };

    var ebml_cluster = function(frame_data, frame_time)
    {
        var parts = [];
        header_int(parts, [0xE7], Math.round(frame_time * 1000)); // Timecode

        header_data(parts, [0xA3], ebml_simpleblock(frame_data)); // SimpleBlock

        return create_data_block([0x1F, 0x43, 0xB6, 0x75], parts); // Cluster
    };

    var ebml_cue_track_positions = function(file_position)
    {
        var parts = [];
        header_int(parts, [0xF7], 1); // CueTrack
        header_int(parts, [0xF1], file_position); // CueClusterPosition
        return create_data_block([0xB7], parts); // CueTrackPositions
    };

    var ebml_cue_point = function(frame_time, file_position)
    {
        var parts = [];
        header_int(parts, [0xB3], Math.round(frame_time * 1000)); // CueTime
        parts.push(ebml_cue_track_positions(file_position));

        return create_data_block([0xBB], parts); // CuePoint
    };

    var ebml_cues = function(frame_times, frame_file_positions)
    {
        var parts = [];
        for(var frame = 0; frame < frame_file_positions.length; ++frame)
        {
            var frame_time = frame_times[frame];
            var file_position = frame_file_positions[frame];
            parts.push(ebml_cue_point(frame_time, file_position));
        }

        return create_data_block([0x1C, 0x53, 0xBB, 0x6B], parts); // Cues
    };

    var ebml_segment = function(parts)
    {
        return create_data_block([0x18, 0x53, 0x80, 0x67], parts); // Segment
    };

    // API:
    // We don't decode the JPEG frames while we do this, so the resolution is supplied here.
    class encode_mkv
    {
        constructor(width, height)
        {
            this.width = width;
            this.height = height;
            this.frames = [];
        }

        add(jpeg_data, frame_duration_ms)
        {
            this.frames.push({
                data: jpeg_data,
                duration: frame_duration_ms,
            });
        };

        build()
        {
            // Sum the duration of the video.
            var duration = 0;
            for(var frame = 0; frame < this.frames.length; ++frame)
            {
                var data = this.frames[frame].data;
                var ms = this.frames[frame].duration;
                duration += ms / 1000.0;
            }

            var header_parts = ebml_header();

            var parts = [];
            parts.push(ebml_info(duration));
            parts.push(ebml_tracks(this.width, this.height));

            // current_pos is the relative position from the start of the segment (after the ID and
            // size bytes) to the beginning of the cluster.
            var current_pos = 0;
            for(var part of parts)
                current_pos += part.byteLength;

            // Create each frame as its own cluster, and keep track of the file position of each.
            var frame_file_positions = [];
            var frame_file_times = [];

            var frame_time = 0;
            for(var frame = 0; frame < this.frames.length; ++frame)
            {
                var data = this.frames[frame].data;
                var ms = this.frames[frame].duration;
                var cluster = ebml_cluster(data, frame_time);
                parts.push(cluster);

                frame_file_positions.push(current_pos);
                frame_file_times.push(frame_time);

                frame_time += ms / 1000.0;
                current_pos += cluster.byteLength;
            };

            // Add the frame index.
            parts.push(ebml_cues(frame_file_times, frame_file_positions));

            // Create an EBMLSegment containing all of the parts (excluding the header).
            var segment = ebml_segment(parts);

            // Return a blob containing the final data.
            var file = [];
            file = file.concat(header_parts);
            file = file.concat(segment);
            return new Blob(file);
        };
    };
    return encode_mkv;
})();
// Hide the mouse cursor when it hasn't moved briefly, to get it out of the way.
// This only hides the cursor over element.
//
// Chrome's cursor handling is buggy and doesn't update the cursor when it's not
// moving, so this only works in Firefox.
var hide_mouse_cursor_on_idle = function(element)
{
    this.onmousemove = this.onmousemove.bind(this);
    this.onblur = this.onblur.bind(this);
    this.idle = this.idle.bind(this);
    this.hide_immediately = this.hide_immediately.bind(this);

    this.element = element;

    this.force_hidden_until = null;

    window.addEventListener("mousemove", this.onmousemove, true);
    window.addEventListener("blur", this.blur, true);
    window.addEventListener("hide-cursor-immediately", this.hide_immediately, true);

    this.reset_timer();
};

hide_mouse_cursor_on_idle.prototype.remove_timer = function()
{
    if(!this.timer)
        return;

    clearInterval(this.timer);
    this.timer = null;
}

// Hide the cursor now, and keep it hidden very briefly even if it moves.  This is done
// when releasing a zoom to prevent spuriously showing the mouse cursor.
hide_mouse_cursor_on_idle.prototype.hide_immediately = function(e)
{
    this.force_hidden_until = Date.now() + 150;
    this.idle();
}

hide_mouse_cursor_on_idle.prototype.reset_timer = function()
{
    this.show_cursor();

    this.remove_timer();
    this.timer = setTimeout(this.idle, 500);
}

hide_mouse_cursor_on_idle.prototype.idle = function()
{
    this.remove_timer();
    this.hide_cursor();
}

hide_mouse_cursor_on_idle.prototype.onmousemove = function(e)
{
    if(this.force_hidden_until && this.force_hidden_until > Date.now())
        return;

    this.reset_timer();
}

hide_mouse_cursor_on_idle.prototype.onblur = function(e)
{
    this.remove_timer();
    this.show_cursor();
}

hide_mouse_cursor_on_idle.prototype.show_cursor = function(e)
{
//    this.element.style.cursor = "";
    this.element.classList.remove("hide-cursor");
}

hide_mouse_cursor_on_idle.prototype.hide_cursor = function(e)
{
    // Setting style.cursor to none doesn't work in Chrome.  Doing it with a style works
    // intermittently (seems to work better in fullscreen).  Firefox doesn't have these
    // problems.
//    this.element.style.cursor = "none";
    this.element.classList.add("hide-cursor");
}
// This handles fetching and caching image data and associated user data.
//
// We always load the user data for an illustration if it's not already loaded.  We also
// load ugoira_metadata.  This way, we can access all the info we need for an image in
// one place, without doing multi-phase loads elsewhere.
class image_data
{
    constructor()
    {
        this.call_pending_callbacks = this.call_pending_callbacks.bind(this);
        this.loaded_image_info = this.loaded_image_info.bind(this);
        this.load_user_info = this.load_user_info.bind(this);
        this.loaded_user_info = this.loaded_user_info.bind(this);

        // Cached data:
        this.image_data = { };
        this.user_data = { };
        this.illust_id_to_user_id = {};

        this.loading_image_data_ids = {};
        this.loading_user_data_ids = {};

        this.pending_image_info_calls = [];
        this.pending_user_info_calls = [];
    };

    // Return the singleton, creating it if needed.
    static singleton()
    {
        if(image_data._singleton == null)
            image_data._singleton = new image_data();
        return image_data._singleton;
    };

    // Get image data.  Call callback when it's available:
    //
    // callback(image_data, user_data);
    //
    // User data for the illustration will be fetched, and returned as image_data.userInfo.
    // Note that user data can change (eg. when following a user), and all images for the
    // same user will share the same userInfo object.
    //
    // If illust_id is a video, we'll also download the metadata before returning it, and store
    // it as image_data.ugoiraMetadata.
    get_image_info(illust_id, callback)
    {
        // If callback is null, just fetch the data.
        if(callback != null)
            this.pending_image_info_calls.push([illust_id, callback]);

        this.load_image_info(illust_id);
    }

    // Just get user info.
    get_user_info(user_id, callback)
    {
        // If callback is null, just fetch the data.
        if(callback != null)
            this.pending_user_info_calls.push([user_id, callback]);

        this.load_user_info(user_id);
    }
    
    call_pending_callbacks()
    {
        // Copy the list, in case get_image_info is called from a callback.
        var callbacks = this.pending_image_info_calls.slice();
        for(var i = 0; i < this.pending_image_info_calls.length; ++i)
        {
            var pending = this.pending_image_info_calls[i];
            var illust_id = pending[0];
            var callback = pending[1];

            // Wait until we have all the info for this image.
            var illust_data = this.image_data[illust_id];
            if(illust_data == null)
                continue;

            var user_data = this.user_data[illust_data.userId];
            if(user_data == null)
                continue;

            // Make sure user_data is referenced from the image.
            illust_data.userInfo = user_data;

            // Remove the entry.
            this.pending_image_info_calls.splice(i, 1);
            --i;

            // Run the callback.
            try {
                callback(illust_data);
            } catch(e) {
                console.error(e);
            }
        }

        // Call user info callbacks.  These are simpler.
        var callbacks = this.pending_user_info_calls.slice();
        for(var i = 0; i < this.pending_user_info_calls.length; ++i)
        {
            var pending = this.pending_user_info_calls[i];
            var user_id = pending[0];
            var callback = pending[1];

            // Wait until we have all the info for this user.
            var user_data = this.user_data[user_id];
            if(user_data == null)
                continue;

            // Remove the entry.
            this.pending_user_info_calls.splice(i, 1);
            --i;

            // Run the callback.
            try {
                callback(user_data);
            } catch(e) {
                console.error(e);
            }
        }
    }

    // Load illust_id and all data that it depends on.  When it's available, call call_pending_callbacks.
    load_image_info(illust_id)
    {
        // If we have the user ID cached, start loading it without waiting for the
        // illustration data to load first.
        var cached_user_id = this.illust_id_to_user_id[illust_id];
        if(cached_user_id != null)
            this.load_user_info(cached_user_id);

        // If we're already loading this illustration, stop.
        if(this.loading_image_data_ids[illust_id])
            return;

        // If we already have this illustration, just make sure we're fetching the user.
        if(this.image_data[illust_id] != null)
        {
            this.load_user_info(this.image_data[illust_id].userId);
            return;
        }

        // console.log("Fetch illust", illust_id);
        this.loading_image_data_ids[illust_id] = true;

        // This call returns only preview data, so we can't use it to batch load data, but we could
        // use it to get thumbnails for a navigation pane:
        // helpers.rpc_get_request("/rpc/illust_list.php?illust_ids=" + illust_id, function(result) { });

        helpers.get_request("/ajax/illust/" + illust_id, {}, this.loaded_image_info);
    }

    loaded_image_info(illust_result)
    {
        if(illust_result == null || illust_result.error)
            return;

        var illust_data = illust_result.body;
        var illust_id = illust_data.illustId;
        // console.log("Got illust", illust_id);

        // This is usually set by load_image_info, but we also need to set it if we're called by
        // add_illust_data so it's true if we fetch metadata below.
        this.loading_image_data_ids[illust_id] = true;

        var finished_loading_image_data = function()
        {
            delete this.loading_image_data_ids[illust_id];

            // Store the image data.
            this.image_data[illust_id] = illust_data;

            // Load user info for the illustration.
            //
            // Do this async rather than immediately, so if we're loading initial info with calls to
            // add_illust_data and add_user_data, we'll give the caller a chance to finish and give us
            // user info, rather than fetching it now when we won't need it.
            setTimeout(function() {
                this.load_user_info(illust_data.userId);
            }.bind(this), 0);
        }.bind(this);

        if(illust_data.illustType == 2)
        {
            // If this is a video, load metadata and add it to the illust_data before we store it.
            helpers.fetch_ugoira_metadata(illust_id, function(ugoira_result) {
                illust_data.ugoiraMetadata = ugoira_result.body;
                finished_loading_image_data();
            }.bind(this));
        }
        else
        {
            // Otherwise, we're done loading the illustration.
            finished_loading_image_data();
        }
    }

    load_user_info(user_id)
    {
        // If we're already loading this user, stop.
        if(this.loading_user_data_ids[user_id])
        {
            console.log("User " + user_id + " is already being fetched, waiting for it");
            return;
        }

        // If we already have the user info for this illustration, we're done.  Call call_pending_callbacks
        // to fire any waiting callbacks.
        if(this.user_data[user_id] != null)
        {
            setTimeout(function() {
                this.call_pending_callbacks();
            }.bind(this), 0);
            return;
        }

        // console.log("Fetch user", user_id);
        this.loading_user_data_ids[user_id] = true;
        helpers.get_request("/ajax/user/" + user_id, {}, this.loaded_user_info);
    }

    loaded_user_info(user_result)
    {
        if(user_result.error)
            return;

        var user_data = user_result.body;
        var user_id = user_data.userId;
        // console.log("Got user", user_id);
        delete this.loading_user_data_ids[user_id];

        // Store the user data.
        this.user_data[user_id] = user_data;

        this.call_pending_callbacks();
    }

    // Add image and user data to the cache that we received from other sources.  Note that if
    // we have any fetches in the air already, we'll leave them running.
    add_illust_data(illust_data)
    {
        // Call loaded_image_info directly, so we'll load video metadata, etc.
        this.loaded_image_info({
            error: false,
            body: illust_data
        });
    }

    add_user_data(user_data)
    {
        this.loaded_user_info({
            body: user_data,
        });
    }

    // When we load an image, we load the user with it, and we get the user ID from
    // the illustration data.  However, this is slow, since we have to wait for
    // the illust request to finish before we know what user to load.
    //
    // In some cases we know from other sources what user we'll need (but where we
    // don't want to load the user yet).  This can be called to cache that, so if
    // an illust is loaded, we can start the user fetch in parallel.
    set_user_id_for_illust_id(illust_id, user_id)
    {
        this.illust_id_to_user_id[illust_id] = user_id;
    }
}

/* Hiding the cursor in CSS is a pain.  It can be done with a global CSS style, but that
 * causes framerate hitches since it causes a global style recalculation.  This class puts
 * a blocker over the whole window to hide the cursor.  This doesn't work in general (it
 * blocks mouse events), but it works fine for click and drag. */
class hide_mouse_cursor
{
    constructor()
    {
        this.blocker = document.createElement("div");
        this.blocker.style.zIndex = 10000;
        this.blocker.style.width = "100%";
        this.blocker.style.height = "100%";
        this.blocker.style.position = "fixed";
        this.blocker.style.top = "0px";
        this.blocker.style.left = "0px";
        this.blocker.style.cursor = "none";
        document.body.appendChild(this.blocker);
    }

    remove()
    {
        if(this.blocker.parentNode == null)
            return;
        this.blocker.parentNode.removeChild(this.blocker);
    }
}

// View img fullscreen.  Clicking the image will zoom it to its original size and scroll
// it around.
//
// The image is always zoomed a fixed amount from its fullscreen size.  This is generally
// more usable than doing things like zooming based on the native resolution.
var on_click_viewer = function(img)
{
    this.onresize = this.onresize.bind(this);
    this.mousedown = this.mousedown.bind(this);
    this.mouseup = this.mouseup.bind(this);
    this.mousemove = this.mousemove.bind(this);
    this.block_event = this.block_event.bind(this);
    this.window_blur = this.window_blur.bind(this);

    // The caller can set this to a function to be called if the user clicks the image without
    // dragging.
    this.clicked_without_scrolling = null;

    this.img = img;
    this.img.style.width = "auto";
    this.img.style.height = "100%";

    this.enable();
};

on_click_viewer.prototype.image_changed = function()
{
    if(this.watch_for_size_available)
    {
        clearInterval(this.watch_for_size_available);
        this.watch_for_size_available = null;
    }

    // Hide the image until we have the size, so it doesn't flicker for one frame in the
    // wrong place.
    this.img.style.display = "none";

    // We need to know the new natural size of the image, but in a huge web API oversight,
    // there's no event for that.  We don't want to wait for onload, since we want to know
    // as soon as it's changed, so we'll set a timer and check periodically until we see
    // a change.
    //
    // However, if we're changing from one image to another, there's no way to know when
    // naturalWidth is updated.  Work around this by loading the image in a second img and
    // watching that instead.  The browser will still only load the image once.
    var dummy_img = document.createElement("img");
    dummy_img.src = this.img.src;

    var image_ready = function() {
        if(dummy_img.naturalWidth == 0)
            return;
        // Store the size.  We can't use the values on this.img, since Firefox sometimes updates
        // them at different times.  (That seems like a bug, since browsers are never supposed to
        // expose internal race conditions to scripts.)
        this.width = dummy_img.naturalWidth;
        this.height = dummy_img.naturalHeight;

        if(this.watch_for_size_available)
            clearInterval(this.watch_for_size_available);
        this.watch_for_size_available = null;

        this.reposition();

        this.img.style.display = "block";
    }.bind(this);

    // If the image is already loaded out of cache, it's ready now.  Checking this now
    // reduces flicker between images.
    if(dummy_img.naturalWidth != 0)
        image_ready();
    else
        this.watch_for_size_available = setInterval(image_ready, 10);
}

on_click_viewer.prototype.block_event = function(e)
{
    e.preventDefault();
}

on_click_viewer.prototype.enable = function()
{
    var target = this.img.parentNode;
    this.event_target = target;
    window.addEventListener("blur", this.window_blur);
    window.addEventListener("resize", this.onresize, true);
    target.addEventListener("mousedown", this.mousedown);
    window.addEventListener("mouseup", this.mouseup);
    target.addEventListener("dragstart", this.block_event);
    target.addEventListener("selectstart", this.block_event);

//    document.documentElement.style.overflow = "hidden";
    target.style.MozUserSelect = "none";
}

on_click_viewer.prototype.disable = function()
{
    if(this.img.parentNode == null)
    {
        console.log("Viewer already disabled");
        return;
    }

    this.stop_dragging();

    this.img.parentNode.removeChild(this.img);

    if(this.watch_for_size_available)
    {
        clearInterval(this.watch_for_size_available);
        this.watch_for_size_available = null;
    }

    if(this.event_target)
    {
        var target = this.event_target;
        this.event_target = null;
        target.removeEventListener("mousedown", this.mousedown);
        target.removeEventListener("dragstart", this.block_event);
        target.removeEventListener("selectstart", this.block_event);
        target.style.MozUserSelect = "";
    }

    window.removeEventListener("blur", this.window_blur);
    window.removeEventListener("resize", this.onresize, true);
    window.removeEventListener("mouseup", this.mouseup);
    window.removeEventListener("mousemove", this.mousemove);
}

on_click_viewer.prototype.onresize = function(e)
{
    this.reposition();
}

on_click_viewer.prototype.window_blur = function(e)
{
    this.stop_dragging();
}

on_click_viewer.prototype.mousedown = function(e)
{
    if(e.button != 0)
        return;

    // We only want clicks on the image, or on the container backing the image, not other
    // elements inside the container.
    if(e.target != this.img && e.target != this.img.parentNode)
        return;

    this.hide_cursor = new hide_mouse_cursor();

    // Don't show the UI if the mouse hovers over it while dragging.
    document.body.classList.add("hide-ui");

    this.zoomed = true;
    this.dragged_while_zoomed = false;

    var img_rect = this.img.getBoundingClientRect();

    // Set the zoom position to the top-left.
    this.zoom_pos = [0,0]; //img_rect.left, img_rect.top];

    // The size of the image being clicked:
    var displayed_width = img_rect.right - img_rect.left;
    var displayed_height = img_rect.bottom - img_rect.top;

    // The offset of the click in pixels relative to the image:
    var distance_from_img = [e.clientX - img_rect.left, e.clientY - img_rect.top];

    // The normalized position clicked in the image (0-1).
    // This adjusts the initial position, so the position clicked stays stationary.
    this.zoom_center = [distance_from_img[0] / displayed_width, distance_from_img[1] / displayed_height];

    this.reposition();

    // Only listen to mousemove while we're dragging.  Put this on window, so we get drags outside
    // the window.
    window.addEventListener("mousemove", this.mousemove);
}

on_click_viewer.prototype.mouseup = function(e)
{
    if(e.button != 0)
        return;

    if(!this.zoomed)
        return;

    // Tell hide_mouse_cursor_on_idle that the mouse cursor should be hidden, even though the
    // cursor may have just been moved.  This prevents the cursor from appearing briefly and
    // disappearing every time a zoom is released.
    window.dispatchEvent(new Event("hide-cursor-immediately"));
    
    this.stop_dragging();
}

on_click_viewer.prototype.stop_dragging = function()
{
    window.removeEventListener("mousemove", this.mousemove);

    if(this.hide_cursor)
    {
        this.hide_cursor.remove();
        this.hide_cursor = null;
    }
    
    document.body.classList.remove("hide-ui");
    
    document.body.style.cursor = "";
    this.zoomed = false;
    this.reposition();
    
    if(!this.dragged_while_zoomed && this.clicked_without_scrolling)
        this.clicked_without_scrolling();
}

on_click_viewer.prototype.mousemove = function(e)
{
    if(!this.zoomed)
        return;

    this.dragged_while_zoomed = true;

    // Apply mouse dragging.
    var x_offset = -e.movementX;
    var y_offset = -e.movementY;
    this.zoom_pos[0] += x_offset * 3;
    this.zoom_pos[1] += y_offset * 3;

    this.reposition();
}

on_click_viewer.prototype.reposition = function()
{
    // Stop if we're being called after being disabled.
    if(this.img.parentNode == null)
        return;

    var totalWidth = this.img.parentNode.offsetWidth;
    var totalHeight = this.img.parentNode.offsetHeight;
    var width = this.width;
    var height = this.height;

    // The ratio to scale the image to fit the screen:
    var zoom_ratio = Math.min(totalWidth/width, totalHeight/height);
    this.zoom_ratio = zoom_ratio;

    height *= this.zoom_ratio;
    width *= this.zoom_ratio;

    // Normally (when unzoomed), the image is centered.
    var left = Math.round((totalWidth - width) / 2);
    var top = Math.round((totalHeight - height) / 2);

    if(this.zoomed) {
        var zoom_level = 2;

        // left is the position of the left side of the image.  We're going to scale around zoom_center,
        // so shift by zoom_center in the unzoomed coordinate space.  If zoom_center[0] is .5, shift
        // the image left by half of its unzoomed width.
        left += this.zoom_center[0] * width;
        top += this.zoom_center[1] * height;

        // Apply the zoom.
        this.zoom_ratio *= zoom_level;
        height *= zoom_level;
        width *= zoom_level;

        // Undo zoom centering in the new coordinate space.
        left -= this.zoom_center[0] * width;
        top -= this.zoom_center[1] * height;
        
        // Apply the position.
        left += this.zoom_pos[0];
        top += this.zoom_pos[1];
    }

    left = Math.round(left);
    top = Math.round(top);
    this.img.style.width = width + "px";
    this.img.style.height = height + "px";
    this.img.style.position = "absolute";
    this.img.style.left = left + "px";
    this.img.style.top = top + "px";
    this.img.style.right = "auto";
    this.img.style.bottom = "auto";
};

var install_polyfills = function()
{
    // Return true if name exists, eg. GM_xmlhttpRequest.
    var script_global_exists = function(name)
    {
        // For some reason, the script globals like GM and GM_xmlhttpRequest aren't
        // in window, so it's not clear how to check if they exist.  Just try to
        // access it and catch the ReferenceError exception if it doesn't exist.
        try {
            eval(name);
            return true;
        } catch(e) {
            return false;
        }
    };

    // If we have GM.xmlHttpRequest and not GM_xmlhttpRequest, set GM_xmlhttpRequest.
    if(script_global_exists("GM") && GM.xmlHttpRequest && !script_global_exists("GM_xmlhttpRequest"))
        window.GM_xmlhttpRequest = GM.xmlHttpRequest;

    // padStart polyfill:
    // https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
    if(!String.prototype.padStart) {
        String.prototype.padStart = function padStart(targetLength,padString) {
            targetLength = targetLength>>0; //truncate if number or convert non-number to 0;
            padString = String((typeof padString !== 'undefined' ? padString : ' '));
            if (this.length > targetLength) {
                return String(this);
            }
            else {
                targetLength = targetLength-this.length;
                if (targetLength > padString.length) {
                    padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed
                }
                return padString.slice(0,targetLength) + String(this);
            }
        };
    }
}

// A simple progress bar.
//
// Call bar.controller() to create a controller to update the progress bar.
class progress_bar
{
    constructor(container)
    {
        this.container = container;

        this.bar = this.container.appendChild(helpers.create_node('\
            <div class=progress-bar> \
            </div> \
        '));

        this.bar.hidden = true;
    };

    // Create a progress_bar_controller for this progress bar.
    //
    // If there was a previous controller, it will be detached.
    controller()
    {
        if(this.current_controller)
        {
            this.current_controller.detach();
            this.current_controller = null;
        }

        this.current_controller = new progress_bar_controller(this);
        return this.current_controller;
    }
}

// This handles updating a progress_bar.
//
// This is separated from progress_bar, which allows us to transparently detach
// the controller from a progress_bar.
//
// For example, if we load a video file and show the loading in the progress bar, and
// the user then navigates to another video, we detach the first controller.  This way,
// the new load will take over the progress bar (whether or not we actually cancel the
// earlier load) and progress bar users won't fight with each other.
class progress_bar_controller
{
    constructor(bar)
    {
        this.progress_bar = bar;
    }

    set(value)
    {
        if(this.progress_bar == null)
            return;

        this.progress_bar.bar.hidden = (value == null);
        this.progress_bar.bar.classList.remove("hide");
        this.progress_bar.bar.getBoundingClientRect();
        if(value != null)
            this.progress_bar.bar.style.width = (value * 100) + "%";
    }

    // Flash the current progress value and fade out.
    show_briefly()
    {
        this.progress_bar.bar.classList.add("hide");
    }

    detach()
    {
        this.progress_bar = null;
    }
};
class seek_bar
{
    constructor(container)
    {
        this.mousedown = this.mousedown.bind(this);
        this.mouseup = this.mouseup.bind(this);
        this.mousemove = this.mousemove.bind(this);
        this.mouseover = this.mouseover.bind(this);
        this.mouseout = this.mouseout.bind(this);

        this.container = container;

        this.bar = this.container.appendChild(helpers.create_node('\
            <div class="seek-bar visible"> \
                <div class=seek-empty> \
                    <div class=seek-fill></div> \
                </div> \
            </div> \
        '));

        this.bar.addEventListener("mousedown", this.mousedown);
        this.bar.addEventListener("mouseover", this.mouseover);
        this.bar.addEventListener("mouseout", this.mouseout);

        this.current_time = 0;
        this.duration = 1;
        this.refresh_visibility();
        this.refresh();
        this.set_callback(null);
    };

    mousedown(e)
    {
        // Never start dragging while we have no callback.  This generally shouldn't happen
        // since we should be hidden.
        if(this.callback == null)
            return;

        if(this.dragging)
            return;

        console.log("down");
        this.dragging = true;
        helpers.set_class(this.bar, "dragging", this.dragging);
        this.refresh_visibility();

        // Only listen to mousemove while we're dragging.  Put this on window, so we get drags outside
        // the window.
        window.addEventListener("mousemove", this.mousemove);
        window.addEventListener("mouseup", this.mouseup);

        this.set_drag_pos(e);
    }

    mouseover()
    {
        this.hovering = true;
        this.refresh_visibility();
    }

    mouseout()
    {
        this.hovering = false;
        this.refresh_visibility();
    }

    refresh_visibility()
    {
        // Show the seek bar if the mouse is over it, or if we're actively dragging.
        // Only show if we're active.
        var visible = this.callback != null && (this.hovering || this.dragging);
        helpers.set_class(this.bar, "visible", visible);
    }

    stop_dragging()
    {
        if(!this.dragging)
            return;

        this.dragging = false;
        helpers.set_class(this.bar, "dragging", this.dragging);
        this.refresh_visibility();

        window.removeEventListener("mousemove", this.mousemove);
        window.removeEventListener("mouseup", this.mouseup);

        if(this.callback)
            this.callback(false, null);
    }

    mouseup(e)
    {
        this.stop_dragging();
    }

    mousemove(e)
    {
        this.set_drag_pos(e);
    }

    // The user clicked or dragged.  Pause and seek to the clicked position.
    set_drag_pos(e)
    {
        // Get the mouse position relative to the seek bar.
        var bounds = this.bar.getBoundingClientRect();
        var pos = (e.clientX - bounds.left) / bounds.width;
        pos = Math.max(0, Math.min(1, pos));
        var time = pos * this.duration;

        // Tell the user to seek.
        this.callback(true, time);
    }

    // Set the callback.  callback(pause, time) will be called when the user interacts
    // with the seek bar.  The first argument is true if the video should pause (because
    // the user is dragging the seek bar), and time is the desired playback time.  If callback
    // is null, remove the callback.
    set_callback(callback)
    {
        this.bar.hidden = callback == null;
        if(this.callback == callback)
            return;

        // Stop dragging on any previous caller before we replace the callback.
        if(this.callback != null)
            this.stop_dragging();

        this.callback = callback;
        this.refresh_visibility();
    };

    set_duration(seconds)
    {
        this.duration = seconds;
        this.refresh();
    };

    set_current_time(seconds)
    {
        this.current_time = seconds;
        this.refresh();
    };

    refresh()
    {
        var position = this.duration > 0.0001? (this.current_time / this.duration):0;
        this.bar.querySelector(".seek-fill").style.width = (position * 100) + "%";
    };
}

// https://github.com/lyngklip/structjs/blob/master/struct.js
// The MIT License (MIT)
// Copyright (c) 2016 Aksel Jensen (TheRealAksel at github)

// This is completely unreadable.  Why would anyone write JS like this?

/*eslint-env es6, node*/
struct = (function() {
    const rechk = /^([<>])?(([1-9]\d*)?([xcbB?hHiIfdsp]))*$/
    const refmt = /([1-9]\d*)?([xcbB?hHiIfdsp])/g
    const str = (v,o,c) => String.fromCharCode(
        ...new Uint8Array(v.buffer, v.byteOffset + o, c))
    const rts = (v,o,c,s) => new Uint8Array(v.buffer, v.byteOffset + o, c)
        .set(s.split('').map(str => str.charCodeAt(0)))
    const pst = (v,o,c) => str(v, o + 1, Math.min(v.getUint8(o), c - 1))
    const tsp = (v,o,c,s) => { v.setUint8(o, s.length); rts(v, o + 1, c - 1, s) }
    const lut = le => ({
        x: c=>[1,c,0],
        c: c=>[c,1,o=>({u:v=>str(v, o, 1)      , p:(v,c)=>rts(v, o, 1, c)     })],
        '?': c=>[c,1,o=>({u:v=>Boolean(v.getUint8(o)),p:(v,B)=>v.setUint8(o,B)})],
        b: c=>[c,1,o=>({u:v=>v.getInt8(   o   ), p:(v,b)=>v.setInt8(   o,b   )})],
        B: c=>[c,1,o=>({u:v=>v.getUint8(  o   ), p:(v,B)=>v.setUint8(  o,B   )})],
        h: c=>[c,2,o=>({u:v=>v.getInt16(  o,le), p:(v,h)=>v.setInt16(  o,h,le)})],
        H: c=>[c,2,o=>({u:v=>v.getUint16( o,le), p:(v,H)=>v.setUint16( o,H,le)})],
        i: c=>[c,4,o=>({u:v=>v.getInt32(  o,le), p:(v,i)=>v.setInt32(  o,i,le)})],
        I: c=>[c,4,o=>({u:v=>v.getUint32( o,le), p:(v,I)=>v.setUint32( o,I,le)})],
        f: c=>[c,4,o=>({u:v=>v.getFloat32(o,le), p:(v,f)=>v.setFloat32(o,f,le)})],
        d: c=>[c,8,o=>({u:v=>v.getFloat64(o,le), p:(v,d)=>v.setFloat64(o,d,le)})],
        s: c=>[1,c,o=>({u:v=>str(v,o,c), p:(v,s)=>rts(v,o,c,s.slice(0,c    ) )})],
        p: c=>[1,c,o=>({u:v=>pst(v,o,c), p:(v,s)=>tsp(v,o,c,s.slice(0,c - 1) )})]
    })
    const errbuf = new RangeError("Structure larger than remaining buffer")
    const errval = new RangeError("Not enough values for structure")
    const struct = format => {
        let fns = [], size = 0, m = rechk.exec(format)
        if (!m) { throw new RangeError("Invalid format string") }
        const t = lut('<' === m[1]), lu = (n, c) => t[c](n ? parseInt(n, 10) : 1)
        while ((m = refmt.exec(format))) { ((r, s, f) => {
            for (let i = 0; i < r; ++i, size += s) { if (f) {fns.push(f(size))} }
        })(...lu(...m.slice(1)))}
        const unpack_from = (arrb, offs) => {
            if (arrb.byteLength < (offs|0) + size) { throw errbuf }
            let v = new DataView(arrb, offs|0)
            return fns.map(f => f.u(v))
        }
        const pack_into = (arrb, offs, ...values) => {
            if (values.length < fns.length) { throw errval }
            if (arrb.byteLength < offs + size) { throw errbuf }
            const v = new DataView(arrb, offs)
            new Uint8Array(arrb, offs, size).fill(0)
            fns.forEach((f, i) => f.p(v, values[i]))
        }
        const pack = (...values) => {
            let b = new ArrayBuffer(size)
            pack_into(b, 0, ...values)
            return b
        }
        const unpack = arrb => unpack_from(arrb, 0)
        function* iter_unpack(arrb) { 
            for (let offs = 0; offs + size <= arrb.byteLength; offs += size) {
                yield unpack_from(arrb, offs);
            }
        }
        return Object.freeze({
            unpack, pack, unpack_from, pack_into, iter_unpack, format, size})
    }
    return struct;
})();

/*
const pack = (format, ...values) => struct(format).pack(...values)
const unpack = (format, buffer) => struct(format).unpack(buffer)
const pack_into = (format, arrb, offs, ...values) =>
    struct(format).pack_into(arrb, offs, ...values)
const unpack_from = (format, arrb, offset) =>
    struct(format).unpack_from(arrb, offset)
const iter_unpack = (format, arrb) => struct(format).iter_unpack(arrb)
const calcsize = format => struct(format).size
module.exports = {
    struct, pack, unpack, pack_into, unpack_from, iter_unpack, calcsize }
*/

// Encode a Pixiv video to MJPEG, using an MKV container.
//
// Other than having to wrangle the MKV format, this is easy: the source files appear to always
// be JPEGs, so we don't need to do any conversions and the encoding is completely lossless (other
// than the loss Pixiv forces by reencoding everything to JPEG).  The result is standard and plays
// in eg. VLC, but it's not a WebM file and browsers don't support it.
var ugoira_downloader_mjpeg = function(illust_data, progress)
{
    this.illust_data = illust_data;
    this.progress = progress;

    // We don't need image data, but we make a dummy canvas to make ZipImagePlayer happy.
    var canvas = document.createElement("canvas");

    // Create a ZipImagePlayer.  This will download the ZIP, and handle parsing the file.
    this.player = new ZipImagePlayer({
        "metadata": illust_data.ugoiraMetadata,
        "source": illust_data.ugoiraMetadata.originalSrc,
        "mime_type": illust_data.ugoiraMetadata.mime_type,
        "canvas": canvas,
        "progress": this.zip_finished_loading.bind(this),
    });            
}

ugoira_downloader_mjpeg.prototype.zip_finished_loading = function(progress)
{
    if(this.progress)
    {
        try {
            this.progress.set(progress);
        } catch(e) {
            console.error(e);
        }
    }

    // We just want to know when the ZIP has been completely downloaded, which is indicated when progress
    // finishes.
    if(progress != null)
        return;

    try {
        var encoder = new encode_mkv(this.illust_data.width,this.illust_data.height);
        
        // Add each frame to the encoder.
        var frame_count = this.illust_data.ugoiraMetadata.frames.length;
        for(var frame = 0; frame < frame_count; ++frame)
        {
            var frame_data = this.player.getFrameData(frame);
            encoder.add(frame_data, this.player.getFrameNoDuration(frame));
        };

        // There's no way to encode the duration of the final frame of an MKV, which means the last frame
        // will be effectively lost when looping.  In theory the duration field on the file should tell the
        // player this, but at least VLC doesn't do that.
        //
        // Work around this by repeating the last frame with a zero duration.
        //
        // In theory we could set the "invisible" bit on this frame ("decoded but not displayed"), but that
        // doesn't seem to be used, at least not by VLC.
        var frame_data = this.player.getFrameData(frame_count-1);
        encoder.add(frame_data, 0);
        
        // Build the file.
        var mkv = encoder.build();
        var filename = this.illust_data.userInfo.name + " - " + this.illust_data.illustId + " - " + this.illust_data.illustTitle + ".mkv";
        helpers.save_blob(mkv, filename);
    } catch(e) {
        console.error(e);
    };
};

// This is the base class for viewer classes, which are used to view a particular
// type of content in the main display.
class viewer
{
    constructor(container, illust_data)
    {
    }

    // Remove any event listeners, nodes, etc. and shut down so a different viewer can
    // be used.
    shutdown() { }
}

// This is the viewer for static images.  We take an illust_data and show
// either a single image or navigate between an image sequence.
//
class viewer_images extends viewer
{
    constructor(container, illust_data, options)
    {
        super(container, illust_data);

        this.illust_data = illust_data;
        this.container = container;
        this.options = options || {};
        this.progress_bar = options.progress_bar;
        this.manga_page_bar = options.manga_page_bar;
        
        this.onkeydown = this.onkeydown.bind(this);

        this.index = options.show_last_image? illust_data.pageCount-1:0;

        // Create the image element.
        this.img = document.createElement("img");
        this.img.className = "filtering";
        container.appendChild(this.img);

        // Create a click and drag viewer for the image.
        this.viewer = new on_click_viewer(this.img);

        // Make a list of image URLs we're viewing.
        this.images = [];

        for(var page = 0; page < illust_data.pageCount; ++page)
            this.images.push(helpers.get_url_for_page(illust_data, page, "original"));

        this.refresh();
    }

    shutdown()
    {
        if(this.viewer)
        {
            this.viewer.disable();
            this.viewer = null;
        }

        if(this.img.parentNode)
            this.img.parentNode.removeChild(this.img);

        if(this.progress_bar)
            this.progress_bar.detach();
    }

    set_page(page)
    {
        this.index = page;
        this.refresh();
    }

    move(down)
    {
        var new_index = this.index + (down? +1:-1);
        new_index = Math.max(0, Math.min(this.images.length-1, new_index));
        if(new_index == this.index)
            return false;

        this.set_page(new_index);
        return true;
    }

    refresh()
    {
        var url = this.images[this.index];
        if(this.viewer && this.img.src == url)
            return;

        this.img.src = url;
        this.viewer.image_changed();

        if(this.options.page_changed)
            this.options.page_changed(this.index, this.images.length, url);

/*        if(this.progress_bar)
        {
            if(this.images.length == 1)
                this.progress_bar.set(null);
            else
            {
                // Flash the current manga page in the progress bar briefly.
                this.progress_bar.set((this.index+1) / this.images.length);
                this.progress_bar.show_briefly();
            }
        } */
        
        // If we have a manga_page_bar, update to show the current page.
        if(this.manga_page_bar)
        {
            if(this.images.length == 1)
                this.manga_page_bar.set(null);
            else
                this.manga_page_bar.set((this.index+1) / this.images.length);
        }
    }

    onkeydown(e)
    {
        switch(e.keyCode)
        {
        case 36: // home
            e.stopPropagation();
            e.preventDefault();
            this.index = 0;
            this.refresh();
            return;

        case 35: // end
            e.stopPropagation();
            e.preventDefault();

            this.index = this.images.length - 1;
            this.refresh();
            return;
        }
    }
}
// This is used to display a muted image.
class viewer_muted extends viewer
{
    constructor(container, illust_data)
    {
        super(container, illust_data);

        this.container = container;

        // Create the display.
        this.root = helpers.create_from_template(".template-muted");
        container.appendChild(this.root);

        // Show the user's avatar instead of the muted image.
        var img = this.root.querySelector(".muted-image");
        img.src = illust_data.userInfo.imageBig;

        var muted_tag = main.any_tag_muted(illust_data.tags.tags);
        var muted_user = main.is_muted_user_id(illust_data.userId);

        var muted_label = this.root.querySelector(".muted-label");
        if(muted_tag)
            muted_label.innerText = muted_tag;
        else
            muted_label.innerText = illust_data.userInfo.name;
    }

    shutdown()
    {
        this.root.parentNode.removeChild(this.root);
    }
}

class viewer_ugoira extends viewer
{
    constructor(container, illust_data, seek_bar, progress)
    {
        super(container, illust_data);
        
        this.refresh_focus = this.refresh_focus.bind(this);
        this.clicked_canvas = this.clicked_canvas.bind(this);
        this.onkeydown = this.onkeydown.bind(this);
        this.drew_frame = this.drew_frame.bind(this);
        this.progress = this.progress.bind(this);
        this.seek_callback = this.seek_callback.bind(this);

        this.illust_data = illust_data;
        this.container = container;
        this.progress_callback = progress;

        this.seek_bar = seek_bar;

        // Create an image to display the static image while we load.
        this.preview_img = document.createElement("img");
        this.preview_img.className = "filtering";
        this.preview_img.style.width = "100%";
        this.preview_img.style.height = "100%";
        this.preview_img.style.objectFit = "contain";
        this.preview_img.src = illust_data.urls.original;
        this.container.appendChild(this.preview_img);

        // Create a canvas to render into.
        this.canvas = document.createElement("canvas");
        this.canvas.hidden = true;
        this.canvas.className = "filtering";
        this.canvas.style.width = "100%";
        this.canvas.style.height = "100%";
        this.canvas.style.objectFit = "contain";
        this.container.appendChild(this.canvas);

        this.canvas.addEventListener("click", this.clicked_canvas, false);

        // True if we want to play if the window has focus.  We always pause when backgrounded.
        this.want_playing = true;

        // True if the user is seeking.  We temporarily pause while seeking.  This is separate
        // from this.want_playing so we stay paused after seeking if we were paused at the start.
        this.seeking = false;

        window.addEventListener("visibilitychange", this.refresh_focus);

        // Create the player.
        this.player = new ZipImagePlayer({
            "metadata": illust_data.ugoiraMetadata,
            "autoStart": false,
            "source": illust_data.ugoiraMetadata.originalSrc,
            "mime_type": illust_data.ugoiraMetadata.mime_type,
            "autosize": true,
            "canvas": this.canvas,
            "loop": true,
            "debug": false,
            "progress": this.progress,
            drew_frame: this.drew_frame,
        });            

        this.refresh_focus();
    }

    progress(value)
    {
        if(this.progress_callback)
            this.progress_callback(value);

        if(value == null)
        {
            // Once we send "finished", don't make any more progress calls.
            this.progress_callback = null;

            // Enable the seek bar once loading finishes.
            if(this.seek_bar)
                this.seek_bar.set_callback(this.seek_callback);
        }
    }

    // Once we draw a frame, hide the preview and show the canvas.  This avoids
    // flicker when the first frame is drawn.
    drew_frame()
    {
        this.preview_img.hidden = true;
        this.canvas.hidden = false;

        if(this.seek_bar)
        {
            // Update the seek bar.
            var frame_time = this.player.getCurrentFrameTime();
            this.seek_bar.set_current_time(this.player.getCurrentFrameTime());
            this.seek_bar.set_duration(this.player.getTotalDuration());
        }
    }

    // This is sent manually by the UI handler so we can control focus better.
    onkeydown(e)
    {
        if(e.keyCode >= 49 && e.keyCode <= 57)
        {
            // 5 sets the speed to default, 1234 slow the video down, and 6789 speed it up.
            e.stopPropagation();
            e.preventDefault();
            if(!this.player)
                return;

            var speed;
            switch(e.keyCode)
            {
            case 49: speed = 0.10; break; // 1
            case 50: speed = 0.25; break; // 2
            case 51: speed = 0.50; break; // 3
            case 52: speed = 0.75; break; // 4
            case 53: speed = 1.00; break; // 5
            case 54: speed = 1.25; break; // 6
            case 55: speed = 1.50; break; // 7
            case 56: speed = 1.75; break; // 8
            case 57: speed = 2.00; break; // 9
            }

            this.player.setSpeed(speed);
            return;
        }

        switch(e.keyCode)
        {
        case 32: // space
            e.stopPropagation();
            e.preventDefault();
            if(this.player)
                this.player.togglePause();
            return;
        case 36: // home
            e.stopPropagation();
            e.preventDefault();
            if(!this.player)
                return;

            this.player.rewind();
            return;

        case 35: // end
            e.stopPropagation();
            e.preventDefault();
            if(!this.player)
                return;

            this.pause();
            this.player.setCurrentFrame(this.player.getFrameCount() - 1);
            return;

        case 39: // right arrow
        case 37: // left arrow
            e.stopPropagation();
            e.preventDefault();
            if(!this.player)
                return;

            this.pause();
            var total_frames = this.player.getFrameCount();
            var current_frame = this.player.getCurrentFrame();
            var next = e.keyCode == 39;
            var new_frame = current_frame + (next?+1:-1);
            this.player.setCurrentFrame(new_frame);
            return;
        }
    }

    play()
    {
        this.want_playing = true;
        this.refresh_focus();
    }

    pause()
    {
        this.want_playing = false;
        this.refresh_focus();
    }

    shutdown()
    {
        this.finished = true;

        if(this.seek_bar)
        {
            this.seek_bar.set_callback(null);
            this.seek_bar = null;
        }

        window.removeEventListener("visibilitychange", this.refresh_focus);

        // Send a finished progress callback if we were still loading.  We won't
        // send any progress calls after this (though the ZipImagePlayer will finish
        // downloading the file anyway).
        this.progress(null);

        if(this.player)
            this.player.pause(); 
        this.preview_img.parentNode.removeChild(this.preview_img);
        this.canvas.parentNode.removeChild(this.canvas);
    }

    refresh_focus()
    {
        if(this.player == null)
            return;

        var active = this.want_playing && !this.seeking && !window.document.hidden;
        if(active)
            this.player.play(); 
        else
            this.player.pause(); 
    };

    clicked_canvas(e)
    {
        this.want_playing = !this.want_playing;
        this.refresh_focus();
    }

    // This is called when the user interacts with the seek bar.
    seek_callback(pause, seconds)
    {
        this.seeking = pause;
        this.refresh_focus();

        if(seconds != null)
            this.player.setCurrentFrameTime(seconds);
    };
}

/*
 * The MIT License (MIT)
 * 
 * Copyright (c) 2014 Pixiv Inc.
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
*/
function ZipImagePlayer(options) {
    this.op = options;
    if (!Blob) {
        this._error("No Blob support");
    }
    if (!Uint8Array) {
        this._error("No Uint8Array support");
    }
    if (!DataView) {
        this._error("No DataView support");
    }
    if (!ArrayBuffer) {
        this._error("No ArrayBuffer support");
    }
    this._loadingState = 0;
    this._dead = false;
    this._context = options.canvas.getContext("2d");
    this._files = {};
    this._frameCount = this.op.metadata.frames.length;
    this._debugLog("Frame count: " + this._frameCount);
    this._frame = 0;
    this._loadFrame = 0;

    // Make a list of timestamps for each frame.
    this._frameTimestamps = [];
    var milliseconds = 0;
    for(var frame of this.op.metadata.frames)
    {
        this._frameTimestamps.push(milliseconds);
        milliseconds += frame.delay;
    }

    this._frameImages = [];
    this._paused = false;
    this._startLoad();
    this.speed = 1;
    if (this.op.autoStart) {
        this.play();
    } else {
        this._paused = true;
    }
}

// Removed partial loading.  It doesn't cache in Firefox, and it's unnecessary with the very
// tiny files Pixiv supports.
ZipImagePlayer.prototype = {
    _failed: false,
    _mkerr: function(msg) {
        var _this = this;
        return function() {
            _this._error(msg);
        }
    },
    _error: function(msg) {
        this._failed = true;
        throw Error("ZipImagePlayer error: " + msg);
    },
    _debugLog: function(msg) {
        if (this.op.debug) {
            console.log(msg);
        }
    },
    _load: function() {
        var _this = this;

        // Use helpers.fetch_resource, so we share fetches with preloading.
        var xhr = helpers.fetch_resource(this.op.source, {
            onload: function(response) {
                if (_this._dead) {
                    return;
                }
                _this._buf = response;
                var length = _this._buf.byteLength;
                _this._len = length;
                _this._pHead = length;
                _this._bytes = new Uint8Array(_this._buf);
                this._findCentralDirectory();

                if(this.op.progress)
                {
                    try {
                        setTimeout(function() {
                            this.op.progress(null);
                        }.bind(this), 0);
                    } catch(e) {
                        console.error(e);
                    }
                }
            }.bind(this),

            onerror: this._mkerr("Fetch failed"),
            onprogress: function(e) {
                if(!this.op.progress)
                    return;
                try {
                    this.op.progress(e.loaded / e.total);
                } catch(e) {
                    console.error(e);
                }
            }.bind(this),
        });
    },
    _startLoad: function() {
        var _this = this;
        if (!this.op.source) {
            // Unpacked mode (individiual frame URLs) - just load the frames.
            this._loadNextFrame();
            return;
        }
        _this._load();
    },
    _findCentralDirectory: function() {
        // No support for ZIP file comment
        var dv = new DataView(this._buf, this._len - 22, 22);
        if (dv.getUint32(0, true) != 0x06054b50) {
            this._error("End of Central Directory signature not found");
        }
        var count = dv.getUint16(10, true);
        var size = dv.getUint32(12, true);
        var offset = dv.getUint32(16, true);
        if (offset < this._pTail) {
            this._error("End central directory past end of file");
            return;
        }

        // Parse the central directory.
        var dv = new DataView(this._buf, offset, size);
        var p = 0;
        for (var i = 0; i < count; i++ ) {
            if (dv.getUint32(p, true) != 0x02014b50) {
                this._error("Invalid Central Directory signature");
            }
            var compMethod = dv.getUint16(p + 10, true);
            var uncompSize = dv.getUint32(p + 24, true);
            var nameLen = dv.getUint16(p + 28, true);
            var extraLen = dv.getUint16(p + 30, true);
            var cmtLen = dv.getUint16(p + 32, true);
            var off = dv.getUint32(p + 42, true);
            if (compMethod != 0) {
                this._error("Unsupported compression method");
            }
            p += 46;
            var nameView = new Uint8Array(this._buf, offset + p, nameLen);
            var name = "";
            for (var j = 0; j < nameLen; j++) {
                name += String.fromCharCode(nameView[j]);
            }
            p += nameLen + extraLen + cmtLen;
            /*this._debugLog("File: " + name + " (" + uncompSize +
                           " bytes @ " + off + ")");*/
            this._files[name] = {off: off, len: uncompSize};
        }
        // Two outstanding fetches at any given time.
        // Note: the implementation does not support more than two.
        if (this._pHead < this._pTail) {
            this._error("Chunk past end of file");
            return;
        }

        this._pHead = this._len;
        this._loadNextFrame();
    },
    _fileDataStart: function(offset) {
        var dv = new DataView(this._buf, offset, 30);
        var nameLen = dv.getUint16(26, true);
        var extraLen = dv.getUint16(28, true);
        return offset + 30 + nameLen + extraLen;
    },
    _isFileAvailable: function(name) {
        var info = this._files[name];
        if (!info) {
            this._error("File " + name + " not found in ZIP");
        }
        if (this._pHead < (info.off + 30)) {
            return false;
        }
        return this._pHead >= (this._fileDataStart(info.off) + info.len);
    },
    getFrameData: function(frame) {
        if (this._dead) {
            return;
        }
        if (frame >= this._frameCount) {
            return null;
        }
        var meta = this.op.metadata.frames[frame];
        if (!this._isFileAvailable(meta.file)) {
            return null;
        }
        var off = this._fileDataStart(this._files[meta.file].off);
        var end = off + this._files[meta.file].len;
        var mime_type = this.op.metadata.mime_type || "image/png";
        var slice;
        if (!this._buf.slice) {
            slice = new ArrayBuffer(this._files[meta.file].len);
            var view = new Uint8Array(slice);
            view.set(this._bytes.subarray(off, end));
        } else {
            slice = this._buf.slice(off, end);
        }
        return slice;
    },
    _loadNextFrame: function() {
        if (this._dead) {
            return;
        }
        var frame = this._loadFrame;
        if (frame >= this._frameCount) {
            return;
        }
        var meta = this.op.metadata.frames[frame];
        if (!this.op.source) {
            // Unpacked mode (individiual frame URLs)
            this._loadFrame += 1;
            this._loadImage(frame, meta.file, false);
            return;
        }
        if (!this._isFileAvailable(meta.file)) {
            return;
        }
        this._loadFrame += 1;
        var off = this._fileDataStart(this._files[meta.file].off);
        var end = off + this._files[meta.file].len;
        var mime_type = this.op.metadata.mime_type || "image/png";
        var slice = this._buf.slice(off, end);
        var blob = new Blob([slice], {type: mime_type});
        /*_this._debugLog("Loading " + meta.file + " to frame " + frame);*/
        var url = URL.createObjectURL(blob);
        this._loadImage(frame, url, true);
    },
    _loadImage: function(frame, url, isBlob) {
        var _this = this;
        var image = document.createElement("img");
        var meta = this.op.metadata.frames[frame];
        image.addEventListener('load', function() {
            _this._debugLog("Loaded " + meta.file + " to frame " + frame);
            if (isBlob) {
                URL.revokeObjectURL(url);
            }
            if (_this._dead) {
                return;
            }
            _this._frameImages[frame] = image;
            if (_this._loadingState == 0) {
                _this._displayFrame.apply(_this);
            }
            if (frame >= (_this._frameCount - 1)) {
                _this._setLoadingState(2);
                _this._buf = null;
                _this._bytes = null;
            } else {
                _this._loadNextFrame();
            }
        });
        image.src = url;
    },
    _setLoadingState: function(state) {
        if (this._loadingState != state) {
            this._loadingState = state;
        }
    },
    _displayFrame: function() {
        if (this._dead) {
            return;
        }
        var _this = this;
        var meta = this.op.metadata.frames[this._frame];
        // this._debugLog("Displaying frame: " + this._frame + " " + meta.file);
        var image = this._frameImages[this._frame];
        if (!image) {
            this._debugLog("Image not available!");
            this._setLoadingState(0);
            return;
        }
        if (this._loadingState != 2) {
            this._setLoadingState(1);
        }
        if (this.op.autosize) {
            if (this._context.canvas.width != image.width || this._context.canvas.height != image.height) {
                // make the canvas autosize itself according to the images drawn on it
                // should set it once, since we don't have variable sized frames
                this._context.canvas.width = image.width;
                this._context.canvas.height = image.height;
            }
        };
        this.drawn_frame = this._frame;
        this._context.clearRect(0, 0, this.op.canvas.width,
                                this.op.canvas.height);
        this._context.drawImage(image, 0, 0);

        // If the user wants to know when the frame is ready, call it.
        if(this.op.drew_frame)
        {
            try {
                setTimeout(function() {
                    this.op.drew_frame(null);
                }.bind(this), 0);
            } catch(e) {
                console.error(e);
            }
        }
        
        if (this._paused)
            return;
        this._pending_frame_metadata = meta;
        this._refreshTimer();
    },
    _unsetTimer: function() {
        if(!this._timer)
            return;

        clearTimeout(this._timer);
        this._timer = null;
    },
    _refreshTimer: function() {
        if(this._paused)
            return;

        this._unsetTimer();
        this._timer = setTimeout(this._nextFrame.bind(this), this._pending_frame_metadata.delay / this.speed);
    },
    getFrameDuration: function() {
        var meta = this.op.metadata.frames[this._frame];
        return meta.delay;
    },
    getFrameNoDuration: function(frame) {
        var meta = this.op.metadata.frames[frame];
        return meta.delay;
    },
    _nextFrame: function(frame) {
        this._timer = null;

        if (this._frame >= (this._frameCount - 1)) {
            if (this.op.loop) {
                this._frame = 0;
            } else {
                this.pause();
                return;
            }
        } else {
            this._frame += 1;
        }
        this._displayFrame();
    },
    play: function() {
        if (this._dead) {
            return;
        }
        if (this._paused) {
            this._paused = false;
            this._displayFrame();
        }
    },
    pause: function() {
        if (this._dead) {
            return;
        }
        if (!this._paused) {
            this._unsetTimer();
            this._paused = true;
        }
    },
    togglePause: function() {
        if(this._paused)
            this.play();
        else
            this.pause();
    },
    rewind: function() {
        if (this._dead) {
            return;
        }
        this._frame = 0;
        this._unsetTimer();
        this._displayFrame();
    },
    setSpeed: function(value) {
        this.speed = value;

        // Refresh the timer, so we don't wait a long time if we're changing from a very slow
        // playback speed.
        this._refreshTimer();
    },
    stop: function() {
        this._debugLog("Stopped");
        this._dead = true;
        this._unsetTimer();
        this._frameImages = null;
        this._buf = null;
        this._bytes = null;
    },
    getCurrentFrame: function() {
        return this._frame;
    },
    setCurrentFrame: function(frame) {
        frame %= this._frameCount;
        if(frame < 0)
            frame += this._frameCount;
        this._frame = frame;
        this._displayFrame();
    },
    getTotalDuration: function() {
        var last_frame = this.op.metadata.frames.length - 1;
        return this._frameTimestamps[last_frame] / 1000;
    },
    getCurrentFrameTime: function() {
        return this._frameTimestamps[this._frame] / 1000;
    },

    // Set the video to the closest frame to the given time.
    setCurrentFrameTime: function(seconds) {
        // We don't actually need to check all frames, but there's no need to optimize this.
        var closest_frame = null;
        var closest_error = null;
        for(var frame = 0; frame < this.op.metadata.frames.length; ++frame)
        {
            var error = Math.abs(seconds - this._frameTimestamps[frame]/1000);
            if(closest_frame == null || error < closest_error)
            {
                closest_frame = frame;
                closest_error = error;
            }
        }

        this._frame = closest_frame;
        this._displayFrame();
    },
    getLoadedFrames: function() {
        return this._frameImages.length;
    },
    getFrameCount: function() {
        return this._frameCount;
    },
    hasError: function() {
        return this._failed;
    }
}

// Display messages in the popup widget.  This is a singleton.
class message_widget
{
    static get singleton()
    {
        if(message_widget._singleton == null)
            message_widget._singleton = new message_widget();
        return message_widget._singleton;
    }

    constructor()
    {
        this.container = document.body.querySelector(".hover-message");
        this.timer = null;
    }

    show(message)
    {
        this.clear_timer();

        this.container.querySelector(".message").innerHTML = message;

        this.container.classList.add("show");
        this.timer = setTimeout(function() {
            this.container.classList.remove("show");
        }.bind(this), 3000);
    }

    clear_timer()
    {
        if(this.timer != null)
        {
            clearTimeout(this.timer);
            this.timer = null;
        }
    }

    hide()
    {
        this.clear_timer();
        this.container.classList.remove("show");
    }
}

class avatar_widget
{
    // options:
    // parent: node to add ourself to (required)
    // changed_callback: called when a follow or unfollow completes
    // big: if true, show the big avatar instead of the small one
    constructor(options)
    {
        this.options = options;
        this.clicked_follow = this.clicked_follow.bind(this);

        this.root = helpers.create_from_template(".template-avatar");
        helpers.set_class(this.root, "big", this.options.big);

        // Show the favorite UI when hovering over the avatar icon.
        var avatar_popup = this.root; //container.querySelector(".avatar-popup");
        avatar_popup.addEventListener("mouseover", function(e) { helpers.set_class(avatar_popup, "popup-visible", true); }.bind(this));
        avatar_popup.addEventListener("mouseout", function(e) { helpers.set_class(avatar_popup, "popup-visible", false); }.bind(this));

        avatar_popup.querySelector(".follow-button.public").addEventListener("click", this.clicked_follow.bind(this, false), false);
        avatar_popup.querySelector(".follow-button.private").addEventListener("click", this.clicked_follow.bind(this, true), false);
        avatar_popup.querySelector(".unfollow-button").addEventListener("click", this.clicked_follow.bind(this, true), false);
        this.element_follow_folder = avatar_popup.querySelector(".folder");

        // Follow publically when enter is pressed on the follow folder input.
        helpers.input_handler(avatar_popup.querySelector(".folder"), this.clicked_follow.bind(this, false));

        this.options.parent.appendChild(this.root);
    }

    set_from_user_data(user_data)
    {
        this.user_data = user_data;

        // We can't tell if we're followed privately or not, only that we're following.
        helpers.set_class(this.root, "followed", this.user_data.isFollowed);

        this.root.querySelector(".avatar-link").href = "/member_illust.php?id=" + user_data.userId;

        // If we don't have an image because we're loaded from a source that doesn't give us them,
        // just hide the avatar image.  Note that this image is low-res even though there's usually
        // a larger version available (grr).
        var element_author_avatar = this.root.querySelector(".avatar");
        var key = this.options.big? "imageBig":"image";
        if(user_data[key])
            element_author_avatar.src = user_data[key];
    }
    
    follow(follow_privately)
    {
        if(this.user_data == null)
            return;

        var username = this.user_data.name;
        var tags = this.element_follow_folder.value;
        helpers.rpc_post_request("/bookmark_add.php", {
            mode: "add",
            type: "user",
            user_id: this.user_data.userId,
            tag: tags,
            restrict: follow_privately? 1:0,
            format: "json",
        }, function(result) {
            if(result == null)
                return;

            // This doesn't return any data.  Record that we're following and refresh the UI.
            this.user_data.isFollowed = true;
            this.set_from_user_data(this.user_data);

            var message = "Followed " + username;
            if(follow_privately)
                message += " privately";
            message_widget.singleton.show(message);
        
            if(this.options.changed_callback)
                this.options.changed_callback();

        }.bind(this));
    }

    unfollow()
    {
        if(this.user_data == null)
            return;

        var username = this.user_data.name;

        helpers.rpc_post_request("/rpc_group_setting.php", {
            mode: "del",
            type: "bookuser",
            id: this.user_data.userId,
        }, function(result) {
            if(result == null)
                return;

            // Record that we're no longer following and refresh the UI.
            this.user_data.isFollowed = false;
            this.set_from_user_data(this.user_data);

            message_widget.singleton.show("Unfollowed " + username);

            if(this.options.changed_callback)
                this.options.changed_callback();
        }.bind(this));
    }

    // Note that in some cases we'll only have the user's ID and name, so we won't be able
    // to tell if we're following.
    clicked_follow(follow_privately, e)
    {
        e.preventDefault();
        e.stopPropagation();

        if(this.user_data == null)
            return;

        if(this.user_data.isFollowed)
        {
            // Unfollow the user.
            this.unfollow();
            return;
        }

        // Follow the user.
        this.follow(follow_privately);
    }
};

// A list of tags, with translations in popups where available.
class tag_widget
{
    // options:
    // parent: node to add ourself to (required)
    // format_link: a function to format a tag to a URL
    constructor(options)
    {
        this.options = options;
        this.container = this.options.parent;
        this.tag_list_container = this.options.parent.appendChild(document.createElement("div"));
        this.tag_list_container.classList.add("tag-list-widget");
    };

    format_tag_link(tag)
    {
        if(this.options.format_link)
            return this.options.format_link(tag);

        var search_url = new URL("/search.php", window.location.href);
        search_url.search = "s_mode=s_tag_full&word=" + tag.tag;
        search_url.hash = "#ppixiv";
        return search_url.toString();
    };

    set(tags)
    {
        // Remove any old tag list and create a new one.
        helpers.remove_elements(this.tag_list_container);

        var tags = tags.tags;
        for(var tag of tags)
        {
            var a = this.tag_list_container.appendChild(document.createElement("a"));
            a.classList.add("tag");
            a.classList.add("box-link");

            // They really can't decide how to store tag translations:
            var popup = null;
            if(tag.translation && tag.translation.en)
                popup = tag.translation.en;
            else if(tag.romaji != null && tag.romaji != "")
                popup = tag.romaji;
            else if(tag.tag_translation != null & tag.tag_translation != "")
                popup = tag.tag_translation;

            var tag_text = tag.tag;

            if(popup && false)
            {
                var swap = tag_text;
                tag_text = popup;
                popup = swap;
            }

            if(popup)
            {
                a.classList.add("popup");
                a.dataset.popup = popup;
            }

            a.dataset.tag = tag_text;
            a.dataset.translatedTag = popup;

            a.textContent = tag_text;

            a.href = this.format_tag_link(tag);
        }

    }
};

// A widget for refreshing bookmark tags.
//
// Pages don't tell us what our bookmark tags are so we can display them.  This
// lets us sync our bookmark tag list with the tags the user has.
class refresh_bookmark_tag_widget
{
    constructor(container)
    {
        this.onclick = this.onclick.bind(this);

        this.container = container;
        this.running = false;
        this.container.addEventListener("click", this.onclick);
    }

    onclick(e)
    {
        if(this.running)
            return;

        this.running = true;
        helpers.set_class(this.container,"spin", this.running);

        helpers.load_data_in_iframe("/bookmark.php", function(document) {
            this.running = false;
            // For some reason, if we disable the spin in this callback, the icon skips
            // for a frame every time (at least in Firefox).  There's no actual processing
            // skip and it doesn't happen if we set the class from a timer.
            setTimeout(function() {
                helpers.set_class(this.container,"spin", this.running);
            }.bind(this), 100);

            var bookmark_tags = [];
            for(var element of document.querySelectorAll("#bookmark_list a[href*='bookmark.php']"))
            {
                var tag = new URL(element.href).searchParams.get("tag");
                if(tag != null)
                    bookmark_tags.push(tag);
            }
            helpers.set_recent_bookmark_tags(bookmark_tags);

            window.dispatchEvent(new Event("bookmark-tags-changed"));
        }.bind(this));
    }
}

// The main UI.  This handles creating the viewers and the global UI.
var main_ui = function(data_source)
{
    if(debug_show_ui) document.body.classList.add("force-ui");

    this.onwheel = this.onwheel.bind(this);
    this.refresh_ui = this.refresh_ui.bind(this);
    this.onkeydown = this.onkeydown.bind(this);
    this.clicked_bookmark = this.clicked_bookmark.bind(this);
    this.clicked_like = this.clicked_like.bind(this);
    this.shown_page_changed = this.shown_page_changed.bind(this);
    this.clicked_download = this.clicked_download.bind(this);
    this.image_data_loaded = this.image_data_loaded.bind(this);
    this.clicked_bookmark_tag_selector = this.clicked_bookmark_tag_selector.bind(this);
    this.refresh_bookmark_tag_highlights = this.refresh_bookmark_tag_highlights.bind(this);
    this.window_onpopstate = this.window_onpopstate.bind(this);
    this.set_image_from_thumbnail = this.set_image_from_thumbnail.bind(this);
    this.toggle_thumbnail_view = this.toggle_thumbnail_view.bind(this);
    this.data_source_updated = this.data_source_updated.bind(this);

    this.current_illust_id = -1;
    this.latest_navigation_direction_down = true;

    this.data_source = data_source;
    this.data_source.add_update_listener(this.data_source_updated);

    window.addEventListener("popstate", this.window_onpopstate);

    // Don't restore the scroll position.
    //
    // If we browser back to a search page and we were scrolled ten pages down, scroll
    // restoration will try to scroll down to it incrementally, causing us to load all
    // data in the search from the top all the way down to where we were.  This can cause
    // us to spam the server with dozens of requests.  This happens on F5 refresh, which
    // isn't useful (if you're refreshing a search page, you want to see new results anyway),
    // and recommendations pages are different every time anyway.
    //
    // This won't affect browser back from an image to the enclosing search.
    history.scrollRestoration = "manual";    

    document.head.appendChild(document.createElement("title"));
    this.document_icon = document.head.appendChild(document.createElement("link"));
    this.document_icon.setAttribute("rel", "icon");
   
    helpers.add_style('body .noise-background { background-image: url("' + binary_data['noise.png'] + '"); };');
    helpers.add_style('body.light .noise-background { background-image: url("' + binary_data['noise-light.png'] + '"); };');
    helpers.add_style('.ugoira-icon { background-image: url("' + binary_data['play-button.svg'] + '"); };');
    helpers.add_style('.page-icon { background-image: url("' + binary_data['page-icon.png'] + '"); };');
    helpers.add_style('.refresh-icon:after { content: url("' + binary_data['refresh-icon.svg'] + '"); };');
    
    helpers.add_style(resources['main.css']);

    // Create the page.
    this.container = document.body.appendChild(helpers.create_node(resources['main.html']));

    new hide_mouse_cursor_on_idle(this.container.querySelector(".image-container"));

    this.thumbnail_view = new thumbnail_view(this.container.querySelector(".thumbnail-container"), this.set_image_from_thumbnail);
    this.thumbnail_view.set_data_source(this.data_source);

    new refresh_bookmark_tag_widget(this.container.querySelector(".refresh-bookmark-tags"));
    this.manga_thumbnails = new manga_thumbnail_widget(this.container.querySelector(".manga-thumbnail-container"));
    this.manga_thumbnails.set_page_changed_callback(function(page) {
        this.viewer.set_page(page);
    }.bind(this));

    this.avatar_widget = new avatar_widget({
        parent: this.container.querySelector(".avatar-popup"),
        changed_callback: this.refresh_ui,
    });

    // Show the bookmark UI when hovering over the bookmark icon.
    var bookmark_popup = this.container.querySelector(".bookmark-button");
    bookmark_popup.addEventListener("mouseover", function(e) { helpers.set_class(bookmark_popup, "popup-visible", true); }.bind(this));
    bookmark_popup.addEventListener("mouseout", function(e) { helpers.set_class(bookmark_popup, "popup-visible", false); }.bind(this));

    bookmark_popup.querySelector(".heart").addEventListener("click", this.clicked_bookmark.bind(this, false), false);
    bookmark_popup.querySelector(".bookmark-button.public").addEventListener("click", this.clicked_bookmark.bind(this, false), false);
    bookmark_popup.querySelector(".bookmark-button.private").addEventListener("click", this.clicked_bookmark.bind(this, true), false);
    bookmark_popup.querySelector(".unbookmark-button").addEventListener("click", this.clicked_bookmark.bind(this, true), false);
    this.element_bookmark_tag_list = bookmark_popup.querySelector(".bookmark-tag-list");

    // Bookmark publically when enter is pressed on the bookmark tag input.
    helpers.input_handler(bookmark_popup.querySelector(".bookmark-tag-list"), this.clicked_bookmark.bind(this, false));


    bookmark_popup.querySelector(".bookmark-tag-selector").addEventListener("click", this.clicked_bookmark_tag_selector);
    this.element_bookmark_tag_list.addEventListener("input", this.refresh_bookmark_tag_highlights);

    // stopPropagation on mousewheel movement inside the bookmark popup, so we allow the scroller to move
    // rather than changing images.
    bookmark_popup.addEventListener("wheel", function(e) { e.stopPropagation(); });

    this.container.querySelector(".download-button").addEventListener("click", this.clicked_download);
    this.container.querySelector(".show-thumbnails-button").addEventListener("click", this.toggle_thumbnail_view);

    window.addEventListener("bookmark-tags-changed", this.refresh_ui);

    this.element_title = this.container.querySelector(".title");
    this.element_author = this.container.querySelector(".author");
    this.element_bookmarked = this.container.querySelector(".bookmark-button");

    this.element_liked = this.container.querySelector(".like-button");
    this.element_liked.addEventListener("click", this.clicked_like, false);

    this.tag_widget = new tag_widget({
        parent: this.container.querySelector(".tag-list"),
    });
    this.element_tags = this.container.querySelector(".tag-list");
    this.element_comment = this.container.querySelector(".description");

    this.container.addEventListener("wheel", this.onwheel);
    window.addEventListener("keydown", this.onkeydown);

    // A bar showing how far along in an image sequence we are:
    this.manga_page_bar = new progress_bar(this.container.querySelector(".ui-box")).controller();
    this.progress_bar = new progress_bar(this.container.querySelector(".loading-progress-bar"));
    this.seek_bar = new seek_bar(this.container.querySelector(".ugoira-seek-bar"));

    helpers.add_clicks_to_search_history(document.body);
    this.refresh_ui();
    
    // Load the initial state.
    this.load_current_state();
}

main_ui.prototype.download_types = ["image", "ZIP", "MKV"];

main_ui.prototype.window_onpopstate = function(e)
{
    // The URL changed, eg. because the user navigated, so load the new state.
    console.log("History state changed");
    this.load_current_state();
}

main_ui.prototype.load_current_state = function()
{
    this.data_source.load_from_current_state(function() {
        // Don't load the default image if the thumbnail view is enabled.
        if(this.thumbnail_view.enabled)
            return;

        // Show the default image.
        var show_illust_id = this.data_source.get_default_illust_id();
        console.log("Showing initial image", show_illust_id);
        this.show_image(show_illust_id);
    }.bind(this));

    this.refresh_ui();
}

// This is called when the user clicks a thumbnail in the thumbnail view to display it.
//
// Normally when we go from one image to another, we leave the previous image viewer in
// place until we have image data for the new image, so we don't flash a black screen.
// That looks ugly when coming from the thumbnail list, since we show whatever previous
// image was being viewed briefly.  Instead, remove the viewer immediately.
main_ui.prototype.set_image_from_thumbnail = function(illust_id)
{
    this.stop_displaying_image();
    
    // Add this to history, since we want browser back to go back to the thumbnails.
    this.show_image(illust_id, false, true /* do add to history */);
}

// Show an image.
//
// If the illustration has multiple pages and show_last_page is true, show the last page
// instead of the first.  This is used when navigating backwards.
//
// If add_to_history is true, we're loading an image because the user navigated to it (eg.
// pressing pgdn), so we should add it to history.  If it's false, we're loading it because
// the history state was changed (eg. browser back), so we shouldn't add a new state.
main_ui.prototype.show_image = function(illust_id, show_last_page, add_to_history)
{
    this.cancel_async_navigation();
    
    // Remember that this is the image we want to be displaying.
    this.wanted_illust_id = illust_id;
    this.wanted_illust_last_page = show_last_page;

    // Tell the preloader about the current image.
    image_preloader.singleton.set_current_image(illust_id);

    // Update the address bar with the new image.
    this.data_source.set_current_illust_id(illust_id, add_to_history);

    // Load info for this image if needed.
    image_data.singleton().get_image_info(illust_id, this.image_data_loaded);
}

// If we started navigating to a new image and were delayed to load data (either to load
// the image or to load a new page), cancel it and stay where we are.
main_ui.prototype.cancel_async_navigation = function()
{
    // If we previously set a pending navigation, this navigation overrides it.
    this.pending_navigation = null;

    // If show_image started loading a new image, unset it.  If add_to_history was
    // true, we won't remove the history entry.
    this.wanted_illust_id = this.current_illust_id;
    if(this.current_illust_id != -1)
        this.data_source.set_current_illust_id(this.current_illust_id, false);
}


// Stop displaying any image (and cancel any wanted navigation), putting us back
// to where we were before displaying any images.
//
// This will also prevent the next image displayed from triggering speculative
// loading, which we don't want to do when clicking an image in the thumbnail
// view.
main_ui.prototype.stop_displaying_image = function()
{
    if(this.viewer != null)
    {
        this.viewer.shutdown();
        this.viewer = null;
    }

    this.manga_thumbnails.set_illust_info(null);
    
    this.wanted_illust_id = null;
    this.wanted_illust_last_page = null;
    this.current_illust_id = -1;
    this.refresh_ui();
}

main_ui.prototype.image_data_loaded = function(illust_data)
{
    var illust_id = illust_data.illustId;

    // If this isn't image data for the image we want to be showing, ignore it.
    if(this.wanted_illust_id != illust_id)
        return;

    console.log("Showing image", illust_id);
    
    var want_last_page = this.wanted_illust_last_page;

    // If true, this is the first image we're displaying.
    var first_image_displayed = this.current_illust_id == -1;

    this.wanted_illust_id = null;
    this.wanted_illust_last_page = null;

    if(illust_id == this.current_illust_id)
    {
        console.log("Image ID not changed");
        return;
    }

    // Speculatively load the next image, which is what we'll show if you press page down, so
    // advancing through images is smoother.
    //
    // We don't do this when showing the first image, since the most common case is simply
    // viewing a single image and not navigating to any others, so this avoids making
    // speculative loads every time you load a single illustration.
    if(!first_image_displayed)
    {
        // Let image_preloader handle speculative loading.  If preload_illust_id is null,
        // we're telling it that we don't need to load anything.
        var preload_illust_id = this.data_source.id_list.get_neighboring_illust_id(illust_id, this.latest_navigation_direction_down);
        image_preloader.singleton.set_speculative_image(preload_illust_id);
    }

    this.current_illust_id = illust_id;
    this.current_illust_data = illust_data;

    this.refresh_ui();

    var illust_data = this.current_illust_data;
    
    // If the image has the ドット絵 tag, enable nearest neighbor filtering.
    helpers.set_class(document.body, "dot", helpers.tags_contain_dot(illust_data));

    // Dismiss any message when changing images.
    message_widget.singleton.hide();
   
    // If we're showing something else, remove it.
    if(this.viewer != null)
    {
        this.viewer.shutdown();
        this.viewer = null;
    }

    this.manga_page_bar.set(null);

    var image_container = this.container.querySelector(".image-container");

    // Check if this image is muted.
    var muted_tag = main.any_tag_muted(illust_data.tags.tags);
    var muted_user = main.is_muted_user_id(illust_data.userId);

    if(muted_tag || muted_user)
    {
        // Tell the thumbnail view about the image.  If the image is muted, disable thumbs.
        this.manga_thumbnails.set_illust_info(null);

        // If the image is muted, load a dummy viewer.
        this.viewer = new viewer_muted(image_container, illust_data);
        return;
    }
 
    // Tell the thumbnail view about the image.
    this.manga_thumbnails.set_illust_info(illust_data);
    this.manga_thumbnails.current_page_changed(want_last_page? (illust_data.pageCount-1):0);
    this.manga_thumbnails.snap_transition();

    // Create the image viewer.
    var progress_bar = this.progress_bar.controller();
    if(illust_data.illustType == 2)
        this.viewer = new viewer_ugoira(image_container, illust_data, this.seek_bar, function(value) {
            progress_bar.set(value);
        }.bind(this));
    else
    {
        this.viewer = new viewer_images(image_container, illust_data, {
            page_changed: this.shown_page_changed,
            progress_bar: progress_bar,
            manga_page_bar: this.manga_page_bar,
            show_last_image: want_last_page,
        });
    }
}

// This is called when the page of a multi-page illustration sequence changes.
main_ui.prototype.shown_page_changed = function(page, total_pages, url)
{
    this.cancel_async_navigation();

    // Let the manga thumbnail display know about the selected page.
    this.manga_thumbnails.current_page_changed(page);
}

main_ui.prototype.data_source_updated = function()
{
    this.refresh_ui();
}

// Refresh the UI for the current image.
main_ui.prototype.refresh_ui = function()
{
    // Don't refresh if the thumbnail view is active.  We're not visible, and we'll just
    // step over its page title, etc.
    if(this.thumbnail_view.enabled)
        return;
    
    // Pull out info about the user and illustration.
    var illust_id = this.current_illust_id;

    // Update the disable UI button to point at the current image's illustration page.
    var disable_button = this.container.querySelector(".disable-ui-button");
    disable_button.href = "/member_illust.php?mode=medium&illust_id=" + illust_id + "#no-ppixiv";

    // If we're not showing an image yet, hide the UI and don't try to update it.
    helpers.set_class(this.container.querySelector(".ui"), "disabled", illust_id == -1);
    if(illust_id == -1)
    {
        helpers.set_page_title("Loading...");
        return;
    }

    var illust_data = this.current_illust_data;
    var user_data = illust_data.userInfo;

    var page_title = "";
    if(illust_data.bookmarkData)
        page_title += "★";
    page_title += user_data.name + " - " + illust_data.illustTitle;
    helpers.set_page_title(page_title);

    helpers.set_page_icon(user_data.isFollowed? binary_data['favorited_icon.png']:binary_data['regular_pixiv_icon.png']);

    // Show the author if it's someone else's post, or the edit link if it's ours.
    var our_post = global_data.user_id == user_data.userId;
    this.container.querySelector(".author-block").hidden = our_post;
    this.container.querySelector(".edit-post").hidden = !our_post;
    this.container.querySelector(".edit-post").href = "/member_illust_mod.php?mode=mod&illust_id=" + illust_id;

    this.avatar_widget.set_from_user_data(user_data);

    // Set the popup for the thumbnails button based on the label of the data source.
    this.container.querySelector(".show-thumbnails-button").dataset.popup = this.data_source.get_displaying_text();

    this.element_author.textContent = user_data.name;
    this.element_author.href = "/member_illust.php?id=" + user_data.userId;

    this.container.querySelector(".similar-illusts-button").href = "/bookmark_detail.php?illust_id=" + illust_id + "#ppixiv";

    this.element_title.textContent = illust_data.illustTitle;
    this.element_title.href = "/member_illust.php?mode=medium&illust_id=" + illust_id;

    // Fill in the post info text.
    var set_info = function(query, text)
    {
        var node = this.container.querySelector(query);
        node.innerText = text;
        node.hidden = text == "";
    }.bind(this);

    var seconds_old = (new Date() - new Date(illust_data.createDate)) / 1000;
    set_info(".post-info > .post-age", helpers.age_to_string(seconds_old) + " ago");

    var info = "";
    if(illust_data.illustType != 2 && illust_data.pageCount == 1)
    {
        // Add the resolution and file type for single images.
        var ext = helpers.get_extension(illust_data.urls.original).toUpperCase();
        info += illust_data.width + "x" + illust_data.height + " " + ext;
    }
    set_info(".post-info > .image-info", info);

    var duration = "";
    if(illust_data.illustType == 2)
    {
        var seconds = 0;
        for(var frame of illust_data.ugoiraMetadata.frames)
            seconds += frame.delay / 1000;

        var duration = seconds.toFixed(duration >= 10? 0:1);
        duration += seconds == 1? " second":" seconds";
    }
    set_info(".post-info > .ugoira-duration", duration);
    set_info(".post-info > .ugoira-frames", illust_data.illustType == 2? (illust_data.ugoiraMetadata.frames.length + " frames"):"");

    // Add the page count for manga.
    set_info(".post-info > .page-count", illust_data.pageCount == 1? "":(illust_data.pageCount + " pages"));

    // The comment (description) can contain HTML.
    this.element_comment.hidden = illust_data.illustComment == "";
    this.element_comment.innerHTML = illust_data.illustComment;
    helpers.fix_pixiv_links(this.element_comment);

    // Set the download button popup text.
    var download_type = this.get_download_type_for_image();
    var download_button = this.container.querySelector(".download-button");
    download_button.hidden = download_type == null;
    if(download_type != null)
        download_button.dataset.popup = "Download " + download_type;

    helpers.set_class(document.body, "bookmarked", illust_data.bookmarkData);

    helpers.set_class(this.element_bookmarked, "bookmarked-public", illust_data.bookmarkData && !illust_data.bookmarkData.private);
    helpers.set_class(this.element_bookmarked, "bookmarked-private", illust_data.bookmarkData && illust_data.bookmarkData.private);
    helpers.set_class(this.element_liked, "liked", illust_data.likeData);
    this.element_liked.dataset.popup = illust_data.likeCount + " likes";
    this.element_bookmarked.querySelector(".popup").dataset.popup = illust_data.bookmarkCount + " bookmarks";

    this.tag_widget.set(illust_data.tags);

    this.refresh_bookmark_tag_list();
}

main_ui.prototype.is_download_type_available = function(download_type)
{
    var illust_data = this.current_illust_data;
    
    // Single image downloading only works for single images.
    if(download_type == "image")
        return illust_data.illustType != 2 && illust_data.pageCount == 1;

    // ZIP downloading only makes sense for image sequences.
    if(download_type == "ZIP")
        return illust_data.illustType != 2 && illust_data.pageCount > 1;

    // MJPEG only makes sense for videos.
    if(download_type == "MKV")
    {
        if(illust_data.illustType != 2)
            return false;

        // All of these seem to be JPEGs, but if any are PNG, disable MJPEG exporting.
        // We could encode to JPEG, but if there are PNGs we should probably add support
        // for APNG.
        if(illust_data.ugoiraMetadata.mime_type != "image/jpeg")
            return false;

        return true;
    }
    throw "Unknown download type " + download_type;
};

main_ui.prototype.get_download_type_for_image = function()
{
    for(var i = 0; i < this.download_types.length; ++i)
    {
        var type = this.download_types[i];
        if(this.is_download_type_available(type))
            return type;
    }

    return null;
}

main_ui.prototype.onwheel = function(e)
{
    // Don't intercept wheel scrolling over the description box.
    if(e.target == this.element_comment)
        return;

    var down = e.deltaY > 0;
    this.move(down);
}

main_ui.prototype.onkeydown = function(e)
{
    if(e.keyCode == 27) // escape
    {
        e.preventDefault();
        e.stopPropagation();

        this.toggle_thumbnail_view();

        return;
    }

    // Don't handle image viewer shortcuts when the thumbnail view is open on top of it.
    if(this.thumbnail_view.enabled)
        return;
    
    // Let the viewer handle the input first.
    if(this.viewer && this.viewer.onkeydown)
    {
        this.viewer.onkeydown(e);
        if(e.defaultPrevented)
            return;
    }

    if(e.keyCode == 66) // b
    {
        // b to bookmark publically, B to bookmark privately, ^B to remove a bookmark.
        //
        // Use a separate hotkey to remove bookmarks, rather than toggling like the bookmark
        // button does, so you don't have to check whether an image is bookmarked.  You can
        // just press B to bookmark without worrying about accidentally removing a bookmark
        // instead.
        e.stopPropagation();
        e.preventDefault();

        var illust_id = this.current_illust_id;
        var illust_data = this.current_illust_data;

        if(e.ctrlKey)
        {
            // Remove the bookmark.
            if(illust_data.bookmarkData == null)
            {
                message_widget.singleton.show("Image isn't bookmarked");
                return;
            }

            this.bookmark_remove();
            return;
        }

        if(illust_data.bookmarkData)
        {
            message_widget.singleton.show("Already bookmarked (^B to remove bookmark)");
            return;
        }
        
        this.bookmark_add(e.shiftKey);
        return;
    }

    if(e.keyCode == 70) // f
    {
        // f to follow publically, F to follow privately, ^F to unfollow.
        e.stopPropagation();
        e.preventDefault();

        var illust_data = this.current_illust_data;
        if(illust_data == null)
            return;

        var user_data = illust_data.userInfo.isFollowed;
        if(e.ctrlKey)
        {
            // Remove the bookmark.
            if(!illust_data.userInfo.isFollowed)
            {
                message_widget.singleton.show("Not following this user");
                return;
            }

            this.avatar_widget.unfollow();
            return;
        }

        if(illust_data.userInfo.isFollowed)
        {
            message_widget.singleton.show("Already following (^F to unfollow)");
            return;
        }
        
        this.avatar_widget.follow(e.shiftKey);
        return;
    }
    
    if(e.ctrlKey || e.altKey)
        return;

    switch(e.keyCode)
    {
    case 86: // l
        e.stopPropagation();
        this.clicked_like(e);
        return;

    case 37: // left
    case 38: // up
    case 33: // pgup
        e.preventDefault();
        e.stopPropagation();

        this.move(false);
        break;

    case 39: // right
    case 40: // down
    case 34: // pgdn
        e.preventDefault();
        e.stopPropagation();

        this.move(true);
        break;
    }
}

main_ui.prototype.toggle_thumbnail_view = function()
{
    this.thumbnail_view.set_enabled(!this.thumbnail_view.enabled, true);

    // Scroll to the current illustration.
    if(this.current_illust_id != -1 && this.thumbnail_view.enabled)
        this.thumbnail_view.scroll_to_illust_id(this.current_illust_id);

    // If we started in the thumbnail view, we didn't load any image, so make sure we
    // display something now.
    if(!this.thumbnail_view.enabled)
        this.load_current_state();

    // If we're enabling the thumbnail, pulse the image that was just being viewed (or
    // loading to be viewed), to
    // make it easier to find your place.
    if(this.thumbnail_view.enabled)
    {
        if(this.current_illust_id != -1)
            this.thumbnail_view.pulse_thumbnail(this.current_illust_id);
        else if(this.wanted_illust_id != -1)
            this.thumbnail_view.pulse_thumbnail(this.wanted_illust_id);
    }
}

main_ui.prototype.move = function(down)
{
    // Remember whether we're navigating forwards or backwards, for preloading.
    this.latest_navigation_direction_down = down;

    this.cancel_async_navigation();

    // Let the viewer handle the input first.
    if(this.viewer && this.viewer.move)
    {
        if(this.viewer.move(down))
            return;
    }

    // Get the next (or previous) illustration after the current one.
    var new_illust_id = this.data_source.id_list.get_neighboring_illust_id(this.current_illust_id, down);
    if(new_illust_id == null)
    {
        // That page isn't loaded.  Try to load it.
        var next_page = this.data_source.id_list.get_page_for_neighboring_illust(this.current_illust_id, down);

        // If we can't find the next page, then the current image isn't actually loaded in
        // the current search results.  This can happen if the page is reloaded: we'll show
        // the previous image, but we won't have the results loaded (and the results may have
        // changed).  Just jump to the first image in the results so we get back to a place
        // we can navigate from.
        //
        // Note that we use id_list.get_first_id rather than get_default_illust_id, which is
        // just the image we're already on.
        if(next_page == null)
        {
            // We should normally know which page the illustration we're currently viewing is on.
            console.warn("Don't know the next page for illust", this.current_illust_id);
            new_illust_id = this.data_source.id_list.get_first_id();
            this.show_image(new_illust_id, false, false /* don't add to history */);
            return true;
        }

        console.log("Loading the next page of results:", next_page);

        // The page shouldn't already be loaded.  Double-check to help prevent bugs that might
        // spam the server requesting the same page over and over.
        if(this.data_source.id_list.is_page_loaded(next_page))
        {
            console.error("Page", next_page, "is already loaded");
            return;
        }

        // Ask the data source to load it.
        var pending_navigation = function()
        {
            // If this.pending_navigation is no longer set to this function, we navigated since
            // we requested this load and this navigation is stale, so stop.
            if(this.pending_navigation != pending_navigation)
            {
                console.log("Aborting stale navigation");
                return;
            }

            this.pending_navigation = null;

            // If we do have an image displayed, navigate up or down based on our most recent navigation
            // direction.  This simply retries the navigation now that we have data.
            console.log("Retrying navigation after data load");
            this.move(down);

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

        if(!this.data_source.load_page(next_page, this.pending_navigation))
        {
            console.log("Reached the end of the list");
            return false;
        }

        return true;
    }

    // Show the new image.  If we're navigating up and there are multiple pages, show
    // the last page instead of the first.
    //
    // We could add to history here, but we don't since it ends up creating way too
    // many history states.
    var show_last_page = !down;
    this.show_image(new_illust_id, show_last_page, false /* don't add to history */);
    return true;
}

main_ui.prototype.clicked_download = function(e)
{
    var clicked_button = e.target.closest(".download-button");
    if(clicked_button == null)
        return;

    e.preventDefault();
    e.stopPropagation();

    var illust_data = this.current_illust_data;

    var download_type = this.get_download_type_for_image();
    if(download_type == null)
    {
        console.error("No download types are available");
        retunr;
    }

    console.log("Download", this.current_illust_id, "with type", download_type);

    if(download_type == "MKV")
    {
        new ugoira_downloader_mjpeg(illust_data, this.progress_bar.controller());
        return;
    }

    if(download_type != "image" && download_type != "ZIP")
    {
        console.error("Unknown download type " + download_type);
        return;
    }

    // Download all images.
    var images = [];
    for(var page = 0; page < illust_data.pageCount; ++page)
        images.push(helpers.get_url_for_page(illust_data, page, "original"));

    var user_data = illust_data.userInfo;
    helpers.download_urls(images, function(results) {
        // If there's just one image, save it directly.
        if(images.length == 1)
        {
            var url = images[0];
            var buf = results[0];
            var blob = new Blob([results[0]]);
            var ext = helpers.get_extension(url);
            var filename = user_data.name + " - " + illust_data.illustId + " - " + illust_data.illustTitle + "." + ext;
            helpers.save_blob(blob, filename);
            return;
        }

        // There are multiple images, and since browsers are stuck in their own little world, there's
        // still no way in 2018 to save a batch of files to disk, so ZIP the images.
        console.log(results);
   
        var filenames = [];
        for(var i = 0; i < images.length; ++i)
        {
            var url = images[i];
            var blob = results[i];

            var ext = helpers.get_extension(url);
            var filename = i.toString().padStart(3, '0') + "." + ext;
            filenames.push(filename);
        }

        // Create the ZIP.
        var zip = new create_zip(filenames, results);
        var filename = user_data.name + " - " + illust_data.illustId + " - " + illust_data.illustTitle + ".zip";
        helpers.save_blob(zip, filename);
    }.bind(this));
    return;
}

main_ui.prototype.clicked_bookmark = function(private_bookmark, e)
{
    e.preventDefault();
    e.stopPropagation();

    var illust_id = this.current_illust_id;
    var illust_data = this.current_illust_data;
    if(illust_data.bookmarkData)
    {
        // The illustration is already bookmarked, so remove the bookmark.
        this.bookmark_remove();
        return;
    }

    // Add a new bookmark.
    this.bookmark_add(private_bookmark);
}

main_ui.prototype.bookmark_add = function(private_bookmark)
{
    var illust_id = this.current_illust_id;
    var illust_data = this.current_illust_data;

    var input_list = this.element_bookmarked.querySelector(".bookmark-tag-list");
    var tags = this.element_bookmark_tag_list.value;
    var tag_list = tags == ""? []:tags.split(" ");

    helpers.update_recent_bookmark_tags(tag_list);

    helpers.post_request("/ajax/illusts/bookmarks/add", {
        "illust_id": illust_id,
        "tags": tag_list,
        "comment": "",
        "restrict": private_bookmark? 1:0,
    }, function(result) {
        if(result == null || result.error)
            return;

        // Clear the tag list after saving a bookmark.  Otherwise, it's too easy to set a tag for one
        // image, then forget to unset it later.
        this.element_bookmark_tag_list.value = null;

        // last_bookmark_id seems to be the ID of the new bookmark.  We need to store this correctly
        // so the unbookmark button works.
        console.log("New bookmark id:", result.body.last_bookmark_id, illust_id);

        illust_data.bookmarkData = {
            "id": result.body.last_bookmark_id,
            "private": private_bookmark,
        }

        illust_data.bookmarkCount++;

        // Refresh the UI if we're still on the same post.
        if(this.current_illust_id == illust_id)
            this.refresh_ui();

        message_widget.singleton.show(private_bookmark? "Bookmarked privately":"Bookmarked");
    }.bind(this));
}

main_ui.prototype.bookmark_remove = function()
{
    var illust_id = this.current_illust_id;
    var illust_data = this.current_illust_data;
    var bookmark_id = illust_data.bookmarkData.id;
    console.log("Remove bookmark", bookmark_id);

    helpers.rpc_post_request("/rpc/index.php", {
        mode: "delete_illust_bookmark",
        bookmark_id: bookmark_id,
    }, function(result) {
        if(result == null || result.error)
            return;

        console.log("Removing bookmark finished");
        illust_data.bookmarkData = false;
        illust_data.bookmarkCount--;

        message_widget.singleton.show("Bookmark removed");

        // Refresh the UI if we're still on the same post.
        if(this.current_illust_id == illust_id)
            this.refresh_ui();
    }.bind(this));
}

// Refresh the list of recent bookmark tags.
main_ui.prototype.refresh_bookmark_tag_list = function()
{
    var bookmark_tags = this.container.querySelector(".bookmark-tag-selector");
    helpers.remove_elements(bookmark_tags);

    var recent_bookmark_tags = helpers.get_recent_bookmark_tags();
    recent_bookmark_tags.sort();
    for(var i = 0; i < recent_bookmark_tags.length; ++i)
    {
        var tag = recent_bookmark_tags[i];
        var entry = helpers.create_from_template(".template-bookmark-tag-entry");
        entry.dataset.tag = tag;
        bookmark_tags.appendChild(entry);
        entry.querySelector(".tag-name").innerText = tag;
    }

    this.refresh_bookmark_tag_highlights();
}

// Update which tags are highlighted in the bookmark tag list.
main_ui.prototype.refresh_bookmark_tag_highlights = function()
{
    var bookmark_tags = this.container.querySelector(".bookmark-tag-selector");
    
    var tags = this.element_bookmark_tag_list.value;
    var tags = tags.split(" ");
    var tag_entries = bookmark_tags.querySelectorAll(".bookmark-tag-entry");
    for(var i = 0; i < tag_entries.length; ++i)
    {
        var entry = tag_entries[i];
        var tag = entry.dataset.tag;
        var highlight_entry = tags.indexOf(tag) != -1;
        helpers.set_class(entry, "enabled", highlight_entry);
    }
}

main_ui.prototype.clicked_bookmark_tag_selector = function(e)
{
    var clicked_tag_entry = e.target.closest(".bookmark-tag-entry");
    var tag = clicked_tag_entry.dataset.tag;

    var clicked_remove = e.target.closest(".remove");
    if(clicked_remove)
    {
        // Remove the clicked tag from the recent list.
        e.preventDefault();
        e.stopPropagation();

        var recent_bookmark_tags = helpers.get_recent_bookmark_tags();
        var idx = recent_bookmark_tags.indexOf(tag);
        if(idx != -1)
            recent_bookmark_tags.splice(idx, 1);
        helpers.set_recent_bookmark_tags(recent_bookmark_tags);
        this.refresh_bookmark_tag_list();
        return;
    }

    // Toggle the clicked tag.
    var tags = this.element_bookmark_tag_list.value;
    var tags = tags == ""? []:tags.split(" ");
    var idx = tags.indexOf(tag);
    if(idx != -1)
    {
        // Remove this tag from the list.
        tags.splice(idx, 1);
    }
    else
    {
        // Add this tag to the list.
        tags.push(tag);
    }

    this.element_bookmark_tag_list.value = tags.join(" ");
    this.refresh_bookmark_tag_highlights();
}

main_ui.prototype.clicked_like = function(e)
{
    e.preventDefault();
    e.stopPropagation();

    var illust_id = this.current_illust_id;
    console.log("Clicked like on", illust_id);

    var illust_data = this.current_illust_data;
    if(illust_data.likeData)
    {
        message_widget.singleton.show("Already liked this image");
        return;
    }
    
    helpers.post_request("/ajax/illusts/like", {
        "illust_id": illust_id,
    }, function() {
        // Update the data (even if it's no longer being viewed).
        illust_data.likeData = true;
        illust_data.likeCount++;

        // Refresh the UI if we're still on the same post.
        if(this.current_illust_id == illust_id)
            this.refresh_ui();

        message_widget.singleton.show("Illustration liked");
    }.bind(this));
}
// This handles the dropdown for an <input> showing recent searches and autocompletion.
// The dropdown will be placed as a sibling of the input, and the parent of both nodes
// should be a position: relative so we can position the dropdown correctly.
class tag_search_dropdown_widget
{
    constructor(input_element)
    {
        this.dropdown_onclick = this.dropdown_onclick.bind(this);
        this.input_onfocus = this.input_onfocus.bind(this);
        this.input_onblur = this.input_onblur.bind(this);
        this.input_onkeydown = this.input_onkeydown.bind(this);
        this.input_oninput = this.input_oninput.bind(this);
        this.autocomplete_request_finished = this.autocomplete_request_finished.bind(this);
        this.parent_onmouseenter = this.parent_onmouseenter.bind(this);
        this.parent_onmouseleave = this.parent_onmouseleave.bind(this);
        this.populate_dropdown = this.populate_dropdown.bind(this);

        this.input_element = input_element;
        this.parent_node = input_element.parentNode;

        this.input_element.addEventListener("focus", this.input_onfocus);
        this.input_element.addEventListener("blur", this.input_onblur);
        this.input_element.addEventListener("keydown", this.input_onkeydown);
        this.input_element.addEventListener("input", this.input_oninput);
        this.parent_node.addEventListener("mouseenter", this.parent_onmouseenter);
        this.parent_node.addEventListener("mouseleave", this.parent_onmouseleave);

        // Refresh the dropdown when the tag search history changes.
        window.addEventListener("recent-tag-searches-changed", this.populate_dropdown);

        // Add the dropdown widget to the input's parent.
        this.tag_dropdown = helpers.create_from_template(".template-tag-dropdown");
        this.tag_dropdown.addEventListener("click", this.dropdown_onclick);
        this.parent_node.appendChild(this.tag_dropdown);

        this.current_autocomplete_results = [];

        this.hide();
        this.populate_dropdown();
    }

    dropdown_onclick(e)
    {
        var remove_entry = e.target.closest(".remove-history-entry");
        if(remove_entry != null)
        {
            // Clicked X to remove a tag from history.
            e.stopPropagation();
            e.preventDefault();
            var tag = e.target.closest(".entry").dataset.tag;
            helpers.remove_recent_search_tag(tag);
            return;
        }
    }

    // Show the dropdown when the input is focused.  Hide it when the input is both
    // unfocused and this.parent_node isn't being hovered.  This way, the input focus
    // can leave the input box to manipulate the dropdown without it being hidden,
    // but we don't rely on hovering to keep the dropdown open.
    input_onfocus(e)
    {
        this.input_focused = true;
        this.show();
    }

    input_onblur(e)
    {
        this.input_focused = false;
        if(!this.input_focused && !this.mouse_over_parent)
            this.hide();
    }

    parent_onmouseenter(e)
    {
        this.mouse_over_parent = true;
    }
    parent_onmouseleave(e)
    {
        this.mouse_over_parent = false;
        if(!this.input_focused && !this.mouse_over_parent)
            this.hide();
    }

    input_onkeydown(e)
    {
        switch(e.keyCode)
        {
        case 38: // up arrow
        case 40: // down arrow
            e.preventDefault();
            e.stopImmediatePropagation();
            this.move(e.keyCode == 40);
            break;
        }
        
    }

    input_oninput(e)
    {
        // Clear the selection on input.
        this.set_selection(null);

        // Update autocomplete when the text changes.
        this.run_autocomplete();
    }

    show()
    {
        this.tag_dropdown.hidden = false;
    }

    hide()
    {
        this.tag_dropdown.hidden = true;
    }

    run_autocomplete()
    {
        // If true, this is a value change caused by keyboard navigation.  Don't run autocomplete,
        // since we don't want to change the dropdown due to navigating in it.
        if(this.navigating)
            return;
        
        var tags = this.input_element.value.trim();

        // Stop if we're already up to date.
        if(this.most_recent_search == tags)
            return;

        if(this.autocomplete_request != null)
        {
            // If an autocomplete request is already running, let it finish before we
            // start another.  This matches the behavior of Pixiv's input forms.
            console.log("Delaying search for", tags);
            return;
        }

        if(tags == "")
        {
            // Don't send requests with an empty string.  Just finish the search synchronously,
            // so we clear the autocomplete immediately.
            this.cancel_autocomplete_request();
            this.autocomplete_request_finished("", { candidates: [] });
            return;
        }

        // Run the search.
        this.autocomplete_request = helpers.rpc_get_request("/rpc/cps.php", {
            keyword: tags,
        }, this.autocomplete_request_finished.bind(this, tags));
    }
    
    cancel_autocomplete_request()
    {
        if(this.autocomplete_request == null)
            return;

        this.autocomplete_request.abort();
        this.autocomplete_request = null;
    }

    // A tag autocomplete request finished.
    autocomplete_request_finished(tags, result)
    {
        this.most_recent_search = tags;
        this.autocomplete_request = null;

        // Store the new results.
        this.current_autocomplete_results = result.candidates || [];
        console.log(result.candidates);

        // Refresh the dropdown with the new results.
        this.populate_dropdown();

        // If the input element's value has changed since we started this search, we
        // stalled any other autocompletion.  Start it now.
        if(tags != this.input_element.value)
        {
            console.log("Run delayed autocomplete");
            this.run_autocomplete();
        }
    }
    
    create_entry(tag)
    {
        var entry = helpers.create_from_template(".template-tag-dropdown-entry");
        entry.dataset.tag = tag;
        entry.querySelector(".tag").innerText = tag;

        var url = page_manager.singleton().get_url_for_tag_search(tag);
        entry.querySelector("A.tag").href = url;
        return entry;
    }

    set_selection(idx)
    {
        // Temporarily set this.navigating to true.  This lets run_autocomplete know that
        // it shouldn't run an autocomplete request for this value change.
        this.navigating = true;
        try {
            // If there's an autocomplete request in the air, cancel it.
            this.cancel_autocomplete_request();

            // Clear any old selection.
            var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry");
            if(this.selected_idx != null)
                all_entries[this.selected_idx].classList.remove("selected");

            // Set the new selection.
            this.selected_idx = idx;
            if(this.selected_idx != null)
            {
                var new_entry = all_entries[this.selected_idx];
                new_entry.classList.add("selected");
                this.input_element.value = new_entry.dataset.tag;
            }
        } finally {
            this.navigating = false;
        }
    }

    // Select the next or previous entry in the dropdown.
    move(down)
    {
        console.log("move down", down);

        var all_entries = this.tag_dropdown.querySelectorAll(".input-dropdown-list .entry");
        console.log(all_entries);

        // Stop if there's nothing in the list.
        var total_entries = all_entries.length;
        if(total_entries == 0)
            return;

        var idx = this.selected_idx;
        if(idx == null)
            idx = down? 0:(total_entries-1);
        else
            idx += down? +1:-1;
        idx %= total_entries;

        this.set_selection(idx);
    }

    populate_dropdown()
    {
        var list = this.tag_dropdown.querySelector(".input-dropdown-list");
        helpers.remove_elements(list);

        var tags = helpers.get_value("recent-tag-searches") || [];
        var autocompleted_tags = this.current_autocomplete_results;
        
        for(var tag of autocompleted_tags)
        {
            var entry = this.create_entry(tag.tag_name);
            entry.classList.add("autocomplete");
            list.appendChild(entry);
        }

        for(var tag of tags)
        {
            var entry = this.create_entry(tag);
            entry.classList.add("history");
            list.appendChild(entry);
        }
    }
}

// This handles batch fetching data for thumbnails.
//
// We can load a bunch of images at once with illust_list.php.  This isn't enough to
// display the illustration, since it's missing a lot of data, but it's enough for
// displaying thumbnails (which is what the page normally uses it for).
class thumbnail_data
{
    constructor()
    {
        this.loaded_thumbnail_info = this.loaded_thumbnail_info.bind(this);

        // Cached data:
        this.thumbnail_data = { };

        // IDs that we're currently requesting:
        this.loading_ids = {};
    };

    // Return the singleton, creating it if needed.
    static singleton()
    {
        if(thumbnail_data._singleton == null)
            thumbnail_data._singleton = new thumbnail_data();
        return thumbnail_data._singleton;
    };

    // Return true if all thumbs in illust_ids have been loaded, or are currently loading.
    //
    // We won't start fetching IDs that aren't loaded.
    are_all_ids_loaded_or_loading(illust_ids)
    {
        for(var illust_id of illust_ids)
        {
            if(this.thumbnail_data[illust_id] == null && !this.loading_ids[illust_id])
                return false;
        }
        return true;
    }
    
    // Return thumbnail data for illud_id, or null if it's not loaded.
    //
    // The thumbnail data won't be loaded if it's not already available.  Use get_thumbnail_info
    // to load thumbnail data in batches.
    get_one_thumbnail_info(illust_id)
    {
        return this.thumbnail_data[illust_id];
    }

    // Return thumbnail data for illust_ids, and start loading any requested IDs that aren't
    // already loaded.
    get_thumbnail_info(illust_ids)
    {
        var result = {};
        var needed_ids = [];
        for(var illust_id of illust_ids)
        {
            var data = this.thumbnail_data[illust_id];
            if(data == null)
            {
                needed_ids.push(illust_id);
                continue;
            }
            result[illust_id] = data;
        }

        // Load any thumbnail data that we didn't have.
        if(needed_ids.length)
            this.load_thumbnail_info(needed_ids);

        return result;
    }

    // Load thumbnail info for the given list of IDs.
    load_thumbnail_info(illust_ids)
    {
        // Make a list of IDs that we're not already loading.
        var ids_to_load = [];
        for(var id of illust_ids)
            if(this.loading_ids[id] == null)
                ids_to_load.push(id);

        if(ids_to_load.length == 0)
            return;

        for(var id of ids_to_load)
            this.loading_ids[id] = true;

        helpers.rpc_get_request("/rpc/illust_list.php", {
            illust_ids: ids_to_load.join(","),

            // Specifying this gives us 240x240 thumbs, which we want, rather than the 150x150
            // ones we'll get if we don't (though changing the URL is easy enough too).
            page: "discover",
        }, this.loaded_thumbnail_info);
    }

    loaded_thumbnail_info(thumb_result)
    {
        if(thumb_result.error)
            return;

        var urls = [];
        for(var thumb_info of thumb_result)
        {
            var illust_id = thumb_info.illust_id;
            delete this.loading_ids[illust_id];

            // Store the data.
            this.thumbnail_data[illust_id] = thumb_info;

            // Don't preload muted images.
            if(!this.is_muted(thumb_info))
                urls.push(thumb_info.url);

            // Let image_data know about the user for this illust, to speed up fetches later.
            image_data.singleton().set_user_id_for_illust_id(thumb_info.illust_id, thumb_info.illust_user_id);
        }

        // Preload thumbnails.
        helpers.preload_images(urls);

        // Broadcast that we have new thumbnail data available.
        window.dispatchEvent(new Event("thumbnailsLoaded"));
    };

    is_muted(thumb_info)
    {
        if(main.is_muted_user_id(thumb_info.illust_user_id))
            return true;
        if(main.any_tag_muted(thumb_info.tags))
            return true;
        return false;
    }
}

// The thumbnail overlay UI.
class thumbnail_view
{
    constructor(container, show_image_callback)
    {
        this.thumbs_loaded = this.thumbs_loaded.bind(this);
        this.data_source_updated = this.data_source_updated.bind(this);
        this.onclick = this.onclick.bind(this);
        this.onwheel = this.onwheel.bind(this);
        this.onscroll = this.onscroll.bind(this);
        this.onpopstate = this.onpopstate.bind(this);
//        this.onmousemove = this.onmousemove.bind(this);
        this.submit_search = this.submit_search.bind(this);
        this.toggle_big_thumbnails = this.toggle_big_thumbnails.bind(this);
        this.toggle_light_mode = this.toggle_light_mode.bind(this);
        this.toggle_disable_thumbnail_panning = this.toggle_disable_thumbnail_panning.bind(this);
        this.toggle_disable_thumbnail_zooming = this.toggle_disable_thumbnail_zooming.bind(this);

        this.container = container;
        this.show_image_callback = show_image_callback;

        window.addEventListener("thumbnailsLoaded", this.thumbs_loaded);
        window.addEventListener("popstate", this.onpopstate);

        this.container.addEventListener("click", this.onclick);
        this.container.addEventListener("wheel", this.onwheel);
//        this.container.addEventListener("mousemove", this.onmousemove);

        this.container.addEventListener("scroll", this.onscroll);
        window.addEventListener("resize", this.onscroll);

        // Create the avatar widget shown on the artist data source.
        this.avatar_widget = new avatar_widget({
            parent: this.container.querySelector(".avatar-container"),
            changed_callback: this.data_source_updated,
            big: true,
        });
        
        // Create the tag widget used by the search data source.
        this.tag_widget = new tag_widget({
            parent: this.container.querySelector(".related-tag-list"),
            format_link: function(tag)
            {
                // The recommended tag links are already on the search page, and retain other
                // search settings.
                var url = new URL(window.location);
                url.searchParams.set("word", tag.tag);
                url.searchParams.delete("p");
                return url.toString();
            }.bind(this),
        });

        // Don't scroll thumbnails when scrolling tag dropdowns.
        // FIXME: This works on member-tags-box, but not reliably on search-tags-box, even though
        // they seem like the same thing.
        this.container.querySelector(".member-tags-box .post-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);
        this.container.querySelector(".search-tags-box .related-tag-list").addEventListener("scroll", function(e) { e.stopPropagation(); }, true);

        // Set up hover popups.
        var setup_popup = function(popup)
        {
            var box = this.container.querySelector(popup);
            box.addEventListener("mouseover", function(e) { helpers.set_class(box, "popup-visible", true); }.bind(this));
            box.addEventListener("mouseout", function(e) { helpers.set_class(box, "popup-visible", false); }.bind(this));
        }.bind(this);

        for(var popup of [".navigation-menu-box", ".settings-menu-box", ".ages-box", ".popularity-box", ".type-box", ".search-mode-box", ".size-box", ".aspect-ratio-box", ".bookmarks-box", ".time-box", ".member-tags-box", ".search-tags-box"])
            setup_popup(popup);

        // Fill in the default value for the search page.  We don't do this in refresh_thumbnail_ui
        // since we don't want to clobber the user's edits later.  Only do this with the search box
        // on the search page, not the one in the navigation dropdown.
        var tag = new URL(document.location).searchParams.get("word");
        if(tag != null)
            this.container.querySelector(".search-page-tag-entry .search-tags").value = tag;

        
        helpers.input_handler(this.container.querySelector(".search-page-tag-entry .search-tags"), this.submit_search);
        helpers.input_handler(this.container.querySelector(".navigation-search-box .search-tags"), this.submit_search);

        this.container.querySelector(".search-page-tag-entry .search-submit-button").addEventListener("click", this.submit_search);
        this.container.querySelector(".navigation-search-box .search-submit-button").addEventListener("click", this.submit_search);

        this.container.querySelector(".toggle-big-thumbnails").addEventListener("click", this.toggle_big_thumbnails);
        this.container.querySelector(".toggle-light-mode").addEventListener("click", this.toggle_light_mode);
        this.container.querySelector(".toggle-thumbnail-zooming").addEventListener("click", this.toggle_disable_thumbnail_zooming);
        this.container.querySelector(".toggle-thumbnail-panning").addEventListener("click", this.toggle_disable_thumbnail_panning);

        // Create the tag dropdown for the search page input.
        new tag_search_dropdown_widget(this.container.querySelector(".tag-search-box .search-tags"));
            
        // Create the tag dropdown for the search input in the menu dropdown.
        new tag_search_dropdown_widget(this.container.querySelector(".navigation-search-box .search-tags"));

        this.update_from_settings();
        this.refresh_images();
        this.load_needed_thumb_data();
    }

    submit_search(e)
    {
        // This can be sent to either the search page search box or the one in the
        // navigation dropdown.  Figure out which one we're on.
        var search_box = e.target.closest(".search-box");
        var tags = search_box.querySelector(".search-tags").value.trim();
        if(tags.length == 0)
            return;

        // Add this tag to the recent search list.
        helpers.add_recent_search_tag(tags);

        // Run the search.
        document.location.href = page_manager.singleton().get_url_for_tag_search(tags);
    }

    /* This scrolls the thumbnail when you hover over it.  It's sort of neat, but it's pretty
     * choppy, and doesn't transition smoothly when the mouse first hovers over the thumbnail,
     * causing it to pop to a new location. 
    onmousemove(e)
    {
        var thumb = e.target.closest(".thumbnail-box a");
        if(thumb == null)
            return;

        var bounds = thumb.getBoundingClientRect();
        var x = e.clientX - bounds.left;
        var y = e.clientY - bounds.top;
        x = 100 * x / thumb.offsetWidth;
        y = 100 * y / thumb.offsetHeight;

        var img = thumb.querySelector("img.thumb");
        img.style.objectPosition = x + "% " + y + "%";
    }
*/
    onclick(e)
    {
        // Only the <A> inside thumbnail-box is clickable.
        var a = e.target.closest("a.thumbnail-link");
        if(a != null)
        {
            // A thumbnail was clicked.  
            e.stopPropagation();
            e.preventDefault();

            var thumb = a.closest(".thumbnail-box");

            var illust_id = thumb.dataset.illust_id;
            if(illust_id == null)
                return;

            this.show_image_callback(illust_id);
            this.enabled = false;
            return;
        }
    };

    onwheel(e)
    {
        // Stop event propagation so we don't change images on any viewer underneath the thumbs.
        e.stopPropagation();
    };

    onscroll(e)
    {
        this.load_needed_thumb_data();
    };

    set_data_source(data_source)
    {
        if(this.data_source != null)
            this.data_source.remove_update_listener(this.data_source_updated);

        this.data_source = data_source;

        if(this.data_source == null)
            return;
        
        // If we disabled loading more pages earlier, reenable it.
        this.disable_loading_more_pages = false;

        // Listen to the data source loading new pages, so we can refresh the list.
        this.data_source.add_update_listener(this.data_source_updated);

        this.set_enabled_from_url();
        this.refresh_images();
        this.load_needed_thumb_data();

        this.refresh_ui();
    };

    refresh_ui()
    {
        if(!this.enabled)
            return;

        var page_title = this.data_source.page_title || "Loading...";
        document.querySelector("title").textContent = page_title;
        
        var ui_box = this.container.querySelector(".thumbnail-ui-box");

        // Show UI elements with this data source in their data-datasource attribute.
        var data_source_name = this.data_source.name;
        for(var node of ui_box.querySelectorAll(".data-source-specific[data-datasource]"))
        {
            var data_sources = node.dataset.datasource.split(" ");
            var show_element = data_sources.indexOf(data_source_name) != -1;
            node.hidden = !show_element;
        }

        var element_displaying = this.container.querySelector(".displaying");
        element_displaying.hidden = this.data_source.get_displaying_text == null;
        if(this.data_source.get_displaying_text != null)
            element_displaying.innerText = this.data_source.get_displaying_text();

        // Set the regular icon.  The data source might change it to something else.
        helpers.set_page_icon(binary_data['regular_pixiv_icon.png']);
        
        // Update the link to bookmarks for the user we're viewing.
        var viewing_user_id = this.data_source.viewing_user_id;
        var viewing_username = this.data_source.viewing_username;
        var show_bookmark_link = viewing_user_id != null && viewing_username != null && viewing_username != global_data.user_id;
        var user_bookmarks = this.container.querySelector(".user-bookmarks");
        user_bookmarks.hidden = !show_bookmark_link;
        if(show_bookmark_link)
        {
            user_bookmarks.href = "/bookmark.php?id=" + viewing_user_id + "#ppixiv";
            user_bookmarks.textContent = viewing_username + "'s Bookmarks";
        }

        this.data_source.refresh_thumbnail_ui(ui_box, this);
    };

    set_enabled_from_url()
    {
        this.set_enabled(this.enabled, false);
    };

    onpopstate(e)
    {
        this.set_enabled_from_url();
    };

    // Show or hide the thumbnail view.
    get enabled()
    {
        // If thumbs is set in the hash, it's whether we're enabled.  Otherwise, use
        // the data source's default.
        var hash_args = page_manager.singleton().get_hash_args();
        var enabled;
        if(!hash_args.has("thumbs"))
            return this.data_source.show_thumbs_by_default;
        else
            return hash_args.get("thumbs") == "1";
    };

    set enabled(enabled)
    {
        this.set_enabled(enabled, false);
    }

    set_enabled(enabled, add_to_history)
    {
        this.container.hidden = !enabled;

        // Update the URL to remember whether we're in the thumb view.
        var query_args = page_manager.singleton().get_query_args();
        var hash_args = page_manager.singleton().get_hash_args();
        if(enabled == this.data_source.show_thumbs_by_default)
            hash_args.delete("thumbs");
        else
            hash_args.set("thumbs", enabled? "1":"0");

        // Dismiss any widget when toggling between views.
        message_widget.singleton.hide();

        page_manager.singleton().set_args(query_args, hash_args, add_to_history);

        if(enabled)
        {
            this.refresh_ui();

            // Refresh the images now, so it's possible to scroll to entries, but wait to start
            // loading data to give the caller a chance to call scroll_to_illust_id(), which needs
            // to happen after refresh_images but before load_needed_thumb_data.  This way, if
            // we're showing a page far from the top, we won't load the first page that we're about
            // to scroll away from.
            this.refresh_images();

            setTimeout(function() {
                this.load_needed_thumb_data();
            }.bind(this), 0);
        }

        if(!enabled)
            this.stop_pulsing_thumbnail();
    }

    data_source_updated()
    {
        this.refresh_images();
        this.load_needed_thumb_data();
        this.refresh_ui();
    }

    // Recreate thumbnail images (the actual <img> elements).
    //
    // This is done when new pages are loaded, to create the correct number of images.
    // We don't need to do this when scrolling around or when new thumbnail data is available.
    refresh_images()
    {
        // Remove all existing entries and collect them.
        var ul = this.container.querySelector("ul.thumbnails");

        // Make a list of [illust_id, page] thumbs to add.
        var images_to_add = [];
        if(this.data_source != null)
        {
            var id_list = this.data_source.id_list;
            var max_page = id_list.get_highest_loaded_page();
            var items_per_page = this.data_source.estimated_items_per_page;
            for(var page = 1; page <= max_page; ++page)
            {
                var illust_ids = id_list.illust_ids_by_page[page];
                if(illust_ids == null)
                {
                    // This page isn't loaded.  Fill the gap with items_per_page blank entries.
                    for(var idx = 0; idx < items_per_page; ++idx)
                        images_to_add.push([null, page]);
                    continue;
                }

                // Create an image for each ID.
                for(var illust_id of illust_ids)
                    images_to_add.push([illust_id, page]);
            }
        }

        // Remove next-page-placeholder while we repopulate.  It's a little different from the other
        // thumbs since it doesn't represent a real entry, so it just complicates the refresh logic.
        var old_placeholder = ul.querySelector(".next-page-placeholder");
        if(old_placeholder)
            ul.removeChild(old_placeholder);

        // Add thumbs.
        //
        // Most of the time we're just adding thumbs to the list.  Avoid removing or recreating
        // thumbs that aren't actually changing, which reduces flicker when adding entries and
        // avoids resetting thumbnail animations.  Do this by looking at the next node in the
        // list and seeing if it matches what we're adding.  When we're done, next_node will
        // point to the first entry that wasn't reused, and we'll remove everything from there onward.
        var next_node = ul.firstElementChild;

        for(var pair of images_to_add)
        {
            var illust_id = pair[0];
            var page = pair[1];

            if(next_node)
            {
                // If the illust_id matches, reuse the entry.  This includes the case where illust_id is
                // null for unloaded page placeholders and we're inserting an identical placeholder.
                if(next_node.dataset.illust_id == illust_id)
                {
                    next_node.dataset.page = page;
                    next_node = next_node.nextElementSibling;
                    continue;
                }

                // If the next node has no illust_id, it's an unloaded page placeholder.  If we're refreshing
                // and now have real entries for that page, we can reuse the placeholders for the real thumbs.
                if(next_node.dataset.illust_id == null && next_node.dataset.page == page)
                {
                    next_node.dataset.illust_id = illust_id;
                    next_node.dataset.page = page;
                    next_node = next_node.nextElementSibling;
                    continue;
                }
            }

            var entry = this.create_thumb(illust_id, page);
            
            // If next_node is null, we've used all existing nodes, so add to the end.  Otherwise,
            // insert before next_node.
            if(next_node != null)
                ul.insertBefore(entry, next_node);
            else
                ul.appendChild(entry);
            
            next_node = entry.nextElementSibling;
        }

        // Remove any images that we didn't use.
        var first_element_to_delete = next_node;
        while(first_element_to_delete != null)
        {
            var next = first_element_to_delete.nextElementSibling;
            ul.removeChild(first_element_to_delete);
            first_element_to_delete = next;
        }

        if(this.data_source != null)
        {
            // Add one dummy thumbnail at the end to represent future images.  If we have one page and
            // this scrolls into view, that tells us we're scrolled near the bottom and should try to
            // load page 2.
            var entry = this.create_thumb(null, max_page+1);
            entry.classList.add("next-page-placeholder");
            entry.hidden = this.disable_loading_more_pages;
            ul.appendChild(entry);
        }
    }

    // Start loading data pages that we need to display visible thumbs, and start
    // loading thumbnail data for nearby thumbs.
    //
    // FIXME: throttle loading pages if we scroll around quickly, so if we scroll
    // down a lot we don't load 10 pages of data
    load_needed_thumb_data()
    {
        // elements is a list of elements that are onscreen (or close to being onscreen).
        // We want thumbnails loaded for these, even if we need to load more thumbnail data.
        //
        // nearby_elements is a list of elements that are a bit further out.  If we load
        // thumbnail data for elements, we'll load these instead.  That way, if we scroll
        // up a bit and two more thumbs become visible, we'll load a bigger chunk.
        // That way, we make fewer batch requests instead of requesting two or three
        // thumbs at a time.

        // Make a list of pages that we need loaded, and illustrations that we want to have
        // set.
        var new_pages = [];
        var wanted_illust_ids = [];
        var need_thumbnail_data = false;

        var elements = this.get_visible_thumbnails(false);
        for(var element of elements)
        {
            if(element.dataset.illust_id == null)
            {
                // This is a placeholder image for a page that isn't loaded, so load the page.
                if(new_pages.indexOf(element.dataset.page) == -1)
                    new_pages.push(element.dataset.page);
            }
            else
            {
                wanted_illust_ids.push(element.dataset.illust_id);
            }
        }

        for(var page of new_pages)
        {
            console.log("Load page", page, "for thumbnails");

            var result = this.data_source.load_page(page);

            // If this page didn't load, it probably means we've reached the end.  Hide
            // the next-page-placeholder image so we don't keep trying to load more pages.
            // This won't prevent us from loading earlier pages.
            if(!result)
                this.disable_loading_more_pages = true;

            // We could load more pages, but let's just load one at a time so we don't spam
            // requests too quickly.  Once this page loads we'll come back here and load
            // another if needed.
            break;
        }

        if(!thumbnail_data.singleton().are_all_ids_loaded_or_loading(wanted_illust_ids))
        {
            // At least one visible thumbnail needs to be loaded, so load more data at the same
            // time.
            var nearby_elements = this.get_visible_thumbnails(true);

            var nearby_illust_ids = [];
            for(var element of nearby_elements)
            {
                if(element.dataset.illust_id == null)
                    continue;
                nearby_illust_ids.push(element.dataset.illust_id);
            }

            // console.log("Wanted:", wanted_illust_ids.join(", "));
            // console.log("Nearby:", nearby_illust_ids.join(", "));

            // Load the thumbnail data if needed.
            thumbnail_data.singleton().get_thumbnail_info(nearby_illust_ids);
        }
        
        this.set_visible_thumbs();
    }

    update_from_settings()
    {
        helpers.set_class(this.container, "big-thumbnails", helpers.get_value("thumbnail-size") == "big");
        this.set_visible_thumbs();

        helpers.set_class(document.body, "light", helpers.get_value("theme") == "light");

        helpers.set_class(document.body, "disable-thumbnail-panning", helpers.get_value("disable_thumbnail_panning"));
        helpers.set_class(document.body, "disable-thumbnail-zooming", helpers.get_value("disable_thumbnail_zooming"));
    }

    toggle_big_thumbnails()
    {
        var big_thumbnails = helpers.get_value("thumbnail-size") == "big";
        big_thumbnails = !big_thumbnails;
        helpers.set_value("thumbnail-size", big_thumbnails? "big":"normal");

        this.update_from_settings();
    }

    toggle_light_mode()
    {
        var light_mode = helpers.get_value("theme") == "light";
        light_mode = !light_mode;
        helpers.set_value("theme", light_mode? "light":"dark");

        this.update_from_settings();
    }

    toggle_disable_thumbnail_panning()
    {
        var disable_panning = helpers.get_value("disable_thumbnail_panning");
        disable_panning = !disable_panning;
        helpers.set_value("disable_thumbnail_panning", disable_panning);

        this.update_from_settings();
    }

    toggle_disable_thumbnail_zooming()
    {
        var disable_zooming = helpers.get_value("disable_thumbnail_zooming");
        disable_zooming = !disable_zooming;
        helpers.set_value("disable_thumbnail_zooming", disable_zooming);

        this.update_from_settings();
    }
     
    // Set the URL for all loaded thumbnails that are onscreen.
    //
    // This won't trigger loading any data (other than the thumbnails themselves).
    set_visible_thumbs()
    {
        // Make a list of IDs that we're assigning.
        var elements = this.get_visible_thumbnails();
        var illust_ids = [];
        for(var element of elements)
        {
            if(element.dataset.illust_id == null)
                continue;
            illust_ids.push(element.dataset.illust_id);
        }        

        // If true, we're loading and showing larger resolution thumbnails.
        var big_thumbnails = helpers.get_value("thumbnail-size") == "big";
        helpers.set_class(this.container, "big-thumbnails", big_thumbnails);

        for(var element of elements)
        {
            var illust_id = element.dataset.illust_id;
            if(illust_id == null)
                continue;

            // Get thumbnail info.
            var info = thumbnail_data.singleton().get_one_thumbnail_info(illust_id);
            if(info == null)
                continue;

            var ugoira = info.illust_type == 2;
            info.illust_page_count;

            // Set this thumb.  Do this even if pending is set, so we update if big_thumbnails has changed.
            var url = info.url;
            if(big_thumbnails)
                url = info.url.replace(/\/240x240\//, "/540x540_70/");

            var thumb = element.querySelector(".thumb");

            // Check if this illustration is muted (blocked).
            var muted_tag = main.any_tag_muted(info.tags);
            var muted_user = main.is_muted_user_id(info.illust_user_id);
            if(muted_tag || muted_user)
            {
                element.classList.add("muted");

                // The image will be obscured, but we still shouldn't load the image the user blocked (which
                // is something Pixiv does wrong).  Load the user profile image instead.
                thumb.src = info.user_profile_img;

                element.querySelector(".muted-label").textContent = muted_tag? muted_tag:info.user_name;

                // We can use this if we want a "show anyway' UI.
                thumb.dataset.mutedUrl = url;
            }
            else
            {
                thumb.src = url;

                // If the aspect ratio is very narrow, don't use any panning, since it becomes too spastic.
                // If the aspect ratio is portrait, use vertical panning.
                // If the aspect ratio is landscape, use horizontal panning.
                //
                // If it's in between, don't pan at all, since we don't have anywhere to move and it can just
                // make the thumbnail jitter in place.
                //
                // Don't pan muted images.
                var aspect_ratio = info.illust_width / info.illust_height;
                var min_aspect_for_pan = 1.1;
                var max_aspect_for_pan = 4;
                var vertical_panning = aspect_ratio > (1/max_aspect_for_pan) && aspect_ratio < 1/min_aspect_for_pan;
                var horizontal_panning = aspect_ratio > min_aspect_for_pan && aspect_ratio < max_aspect_for_pan;
                helpers.set_class(element, "vertical-panning", vertical_panning);
                helpers.set_class(element, "horizontal-panning", horizontal_panning);
            }

            // Leave it alone if it's already been loaded.
            if(!("pending" in element.dataset))
                continue;

            // Why is this not working in FF?  It works in the console, but not here.  Sandboxing
            // issue?
            // delete element.dataset.pending;
            element.removeAttribute("data-pending");

            // Set the link.  We'll capture clicks and navigate in-page, but this allows middle click, etc.
            // to work normally.
            element.querySelector("a.thumbnail-link").href = "/member_illust.php?mode=medium&illust_id=" + illust_id + "#ppixiv";

            if(info.illust_type == 2)
                element.querySelector(".ugoira-icon").hidden = false;

            if(info.illust_page_count > 1)
            {
                element.querySelector(".page-count-box").hidden = false;
                element.querySelector(".page-count-box .page-count").textContent = info.illust_page_count;
            }


            helpers.set_class(element, "dot", helpers.tags_contain_dot(info));

            // Set the popup.
            var a = element.querySelector(".thumbnail-inner");
            var popup = info.user_name + ": " + info.illust_title;
            a.classList.add("popup");
            a.dataset.popup = popup;
        }        
    }

    // Return a list of thumbnails that are either visible, or close to being visible
    // (so we load thumbs before they actually come on screen).
    //
    // If extra is true, return more offscreen thumbnails.
    get_visible_thumbnails(extra)
    {
        // If the container has a zero height, that means we're hidden and we don't want to load
        // thumbnail data at all.
        if(this.container.offsetHeight == 0)
            return [];

        // We'll load thumbnails when they're within this number of pixels from being onscreen.
        var threshold = 450;

        var ul = this.container.querySelector("ul.thumbnails");
        var elements = [];
        var bounds_top = this.container.scrollTop - threshold;
        var bounds_bottom = this.container.scrollTop + this.container.offsetHeight + threshold;
        for(var element = ul.firstElementChild; element != null; element = element.nextElementSibling)
        {
            if(element.offsetTop + element.offsetHeight < bounds_top)
                continue;
            if(element.offsetTop > bounds_bottom)
                continue;
            elements.push(element);
        }

        if(extra)
        {
            // Expand the list outwards to include more thumbs.
            var expand_by = 20;
            var expand_upwards = true;
            while(expand_by > 0)
            {
                if(!elements[0].previousElementSibling && !elements[elements.length-1].nextElementSibling)
                {
                    // Stop if there's nothing above or below our results to add.
                    break;
                }

                if(!expand_upwards && elements[0].previousElementSibling)
                {
                    elements.unshift(elements[0].previousElementSibling);
                    expand_by--;
                }
                else if(expand_upwards && elements[elements.length-1].nextElementSibling)
                {
                    elements.push(elements[elements.length-1].nextElementSibling);
                    expand_by--;
                }

                expand_upwards = !expand_upwards;
            }
        }
        return elements;
    }

    // Create a thumb placeholder.  This doesn't load the image yet.
    //
    // illust_id is the illustration this will be if it's displayed, or null if this
    // is a placeholder for pages we haven't loaded.  page is the page this illustration
    // is on (whether it's a placeholder or not).
    create_thumb(illust_id, page)
    {
        var entry = helpers.create_from_template(".template-thumbnail");

        // Mark that this thumb hasn't been filled in yet.
        entry.dataset.pending = true;

        if(illust_id != null)
            entry.dataset.illust_id = illust_id;
        entry.dataset.page = page;
        return entry;
    }

    // This is called when thumbnail_data has loaded more thumbnail info.
    thumbs_loaded(e)
    {
        this.set_visible_thumbs();
    }

    // Scroll to illust_id if it's available.  This is called when we display the thumbnail view
    // after coming from an illustration.
    scroll_to_illust_id(illust_id)
    {
        var thumb = this.container.querySelector("li[data-illust_id='" + illust_id + "']");
        if(thumb == null)
            return;

        // scrollIntoView scrolls even if the item is already in view, which doesn't make sense, so
        // we have to manually check.
        unsafeWindow.fff = thumb;
        var scroll_pos = this.container.scrollTop;
        if(thumb.offsetTop < scroll_pos || thumb.offsetTop + thumb.offsetHeight > scroll_pos + this.container.offsetHeight)
            thumb.scrollIntoView();
    };

    pulse_thumbnail(illust_id)
    {
        var thumb = this.container.querySelector("li[data-illust_id='" + illust_id + "']");
        if(thumb == null)
            return;

        this.stop_pulsing_thumbnail();

        this.flashing_image = thumb;
        thumb.classList.add("flash");
    };

    // Work around a bug in CSS animations: even if animation-iteration-count is 1,
    // the animation will play again if the element is hidden and displayed again, which
    // causes previously-flashed thumbnails to flash every time we exit and reenter
    // thumbnails.
    stop_pulsing_thumbnail()
    {
        if(this.flashing_image == null)
            return;

        this.flashing_image.classList.remove("flash");
        this.flashing_image = null;
    };
};

class scroll_handler
{
    constructor(container)
    {
        this.container = container;
    }

    // Bring item into view.  We'll also try to keep the next and previous items visible.
    scroll_into_view(item)
    {
        // Make sure item is a direct child of the container.
        if(item.parentNode != this.container)
        {
            console.error("Node", item, "isn't in scroller", this.container);
            return;
        }

        // Scroll so the items to the left and right of the current thumbnail are visible,
        // so you can tell whether there's another entry to scroll to.  If we can't fit
        // them, center the selection.
        var scroller_left = this.container.getBoundingClientRect().left;
        var left = item.offsetLeft - scroller_left;
        
        if(item.previousElementSibling)
            left = Math.min(left, item.previousElementSibling.offsetLeft - scroller_left);

        var right = item.offsetLeft + item.offsetWidth - scroller_left;
        if(item.nextElementSibling)
            right = Math.max(right, item.nextElementSibling.offsetLeft + item.nextElementSibling.offsetWidth - scroller_left);

        var new_left = this.container.scrollLeft;
        if(new_left > left)
            new_left = left;
        if(new_left + this.container.offsetWidth < right)
            new_left = right - this.container.offsetWidth;
        this.container.scrollLeft = new_left;

        // If we didn't fit the previous and next entries, there isn't enough space.  This
        // might be a wide thumbnail or the window might be very narrow.  Just center the
        // selection.  Note that we need to compare against the value we assigned and not
        // read scrollLeft back, since the API is broken and reads back the smoothed value
        // rather than the target we set.
        if(new_left > left ||
           new_left + this.container.offsetWidth < right)
        {
            this.center_item(item);
        }
    }

    // Scroll the given item to the center.
    center_item(item)
    {
        var scroller_left = this.container.getBoundingClientRect().left;
        var left = item.offsetLeft - scroller_left;
        left += item.offsetWidth/2;
        left -= this.container.offsetWidth / 2;
        this.container.scrollLeft = left;
    }

    /* Snap to the target position, cancelling any smooth scrolling. */
    snap()
    {
        this.container.style.scrollBehavior = "auto";
        if(this.container.firstElementChild)
            this.container.firstElementChild.getBoundingClientRect();
        this.container.getBoundingClientRect();
        this.container.style.scrollBehavior = "";
    }
};

class manga_thumbnail_widget
{
    constructor(container)
    {
        this.onclick = this.onclick.bind(this);
        this.onmouseenter = this.onmouseenter.bind(this);
        this.onmouseleave = this.onmouseleave.bind(this);
        this.check_image_loads = this.check_image_loads.bind(this);
        this.window_onresize = this.window_onresize.bind(this);
        
        window.addEventListener("resize", this.window_onresize);

        this.container = container;
        this.container.addEventListener("click", this.onclick);
        this.container.addEventListener("mouseenter", this.onmouseenter);
        this.container.addEventListener("mouseleave", this.onmouseleave);

        this.cursor = document.createElement("div");
        this.cursor.classList.add("thumb-list-cursor");

        this.scroll_box = this.container.querySelector(".manga-thumbnails");
        this.scroller = new scroll_handler(this.scroll_box);

        this.visible = false;
        this.set_illust_info(null);
    }

    // Both Firefox and Chrome have some nasty layout bugs when resizing the window,
    // causing the flexbox and the images inside it to be incorrect.  Work around it
    // by forcing a refresh.
    window_onresize(e)
    {
        this.refresh();
    }

    onmouseenter(e)
    {
        this.hovering = true;
        this.refresh_visible();
    }

    onmouseleave(e)
    {
        this.stop_hovering();
    }

    stop_hovering()
    {
        this.hovering = false;
        this.refresh_visible();
    }

    refresh_visible()
    {
        this.visible = this.hovering;
    }

    get visible()
    {
        return this.container.classList.contains("visible");
    }

    set visible(visible)
    {
        if(visible == this.visible)
            return;

        helpers.set_class(this.container, "visible", visible);

        if(!visible)
            this.stop_hovering();
    }

    onclick(e)
    {
        var arrow = e.target.closest(".manga-thumbnail-arrow");
        if(arrow != null)
        {
            e.preventDefault();
            e.stopPropagation();

            var left = arrow.dataset.direction == "left";
            console.log("scroll", left);

            var new_page = this.current_page + (left? -1:+1);
            if(new_page < 0 || new_page >= this.entries.length)
                return;

            if(this.page_changed_callback)
                this.page_changed_callback(new_page);
            /*
            var entry = this.entries[new_page];
            if(entry == null)
                return;

            this.scroller.scroll_into_view(entry);
            
            */
            return;
        }

        var thumb = e.target.closest(".manga-thumbnail-box");
        if(thumb != null)
        {
            e.preventDefault();
            e.stopPropagation();

            if(this.page_changed_callback)
                this.page_changed_callback(parseInt(thumb.dataset.page));
            return;
        }
    }

    set_illust_info(illust_info)
    {
        if(illust_info == this.illust_info)
            return;

        // Only display if we have at least two pages.
        if(illust_info != null && illust_info.pageCount < 2)
            illust_info = null;

        // If we're not on a manga page, hide ourselves entirely, including the hover box.
        this.container.hidden = illust_info == null;

        this.illust_info = illust_info;

        if(illust_info == null)
            this.stop_hovering();

        // Refresh the thumb images.
        this.refresh();

        // Start or stop check_image_loads if needed.
        if(this.illust_info == null && this.check_image_loads_timer != null)
        {
            clearTimeout(this.check_image_loads_timer);
            this.check_image_loads_timer = null;
        }
        this.check_image_loads();
    }

    snap_transition()
    {
        this.scroller.snap();
    }

    // Set a callback(page) to call when the user clicks a page.
    set_page_changed_callback(callback)
    {
        this.page_changed_callback = callback;
    }

    // This is called when the manga page is changed externally.
    current_page_changed(page)
    {
        // Ignore page changes if we're not displaying anything.
        if(this.illust_info == null)
            return
        
        this.current_page = page;

        // Find the entry for the page.
        var entry = this.entries[this.current_page];
        if(entry == null)
        {
            console.error("Scrolled to unknown page", this.current_page);
            return;
        }

        this.scroller.scroll_into_view(entry);

        if(this.selected_entry)
            helpers.set_class(this.selected_entry, "selected", false);

        this.selected_entry = entry;

        if(this.selected_entry)
        {
            helpers.set_class(this.selected_entry, "selected", true);

            this.update_cursor_position();
        }
    }

    update_cursor_position()
    {
        // Wait for images to know their size before positioning the cursor.
        if(this.selected_entry == null || this.waiting_for_images || this.cursor.parentNode == null)
            return;

        // Position the cursor to the position of the selection.
        this.cursor.style.width = this.selected_entry.offsetWidth + "px";

        var scroller_left = this.scroll_box.getBoundingClientRect().left;
        var base_left = this.cursor.parentNode.getBoundingClientRect().left;
        var position_left = this.selected_entry.getBoundingClientRect().left;
        var left = position_left - base_left;
        this.cursor.style.left = left + "px";
    }

    // We can't update the UI properly until we know the size the thumbs will be,
    // and the site doesn't tell us the size of manga pages (only the first page).
    // Work around this by hiding until we have naturalWidth for all images, which
    // will allow layout to complete.  There's no event for this for some reason,
    // so the only way to detect it is with a timer.
    //
    // This often isn't needed because of image preloading.
    check_image_loads()
    {
        if(this.illust_info == null)
            return;

        this.check_image_loads_timer = null;
        var all_images_loaded = true;
        for(var img of this.container.querySelectorAll("img.manga-thumb"))
        {
            if(img.naturalWidth == 0)
                all_images_loaded = false;
        }

        // If all images haven't loaded yet, check again.
        if(!all_images_loaded)
        {
            this.waiting_for_images = true;
            this.check_image_loads_timer = setTimeout(this.check_image_loads, 10);
            return;
        }
        this.waiting_for_images = false;

        // Now that we know image sizes and layout can update properly, we can update the cursor's position.
        this.update_cursor_position();
    }

    refresh()
    {
        if(this.cursor.parentNode)
            this.cursor.parentNode.removeChild(this.cursor);

        var ul = this.container.querySelector(".manga-thumbnails");
        helpers.remove_elements(ul);
        this.entries = [];

        if(this.illust_info == null)
            return;

        // Add left and right padding elements to center the list if needed.
        var left_padding = document.createElement("div");
        left_padding.style.flex = "1";
        ul.appendChild(left_padding);

        for(var page = 0; page < this.illust_info.pageCount; ++page)
        {
            var url = helpers.get_url_for_page(this.illust_info, page, "thumb");
        
            var img = document.createElement("img");
            var entry = helpers.create_from_template(".template-manga-thumbnail");
            entry.dataset.page = page;
            entry.querySelector("img.manga-thumb").src = url;
            ul.appendChild(entry);
            this.entries.push(entry);
        }
        
        var right_padding = document.createElement("div");
        right_padding.style.flex = "1";
        ul.appendChild(right_padding);

        // Place the cursor inside the first entry, so it follows it around as we scroll.
        this.entries[0].appendChild(this.cursor);

        this.update_cursor_position();
    }
};

// This handles:
//
// - Keeping track of whether we're active or not.  If we're inactive, we turn off
// and let the page run normally.
// - Storing state in the address bar.
//
// We're active by default on illustration pages, and inactive by default on others.
//
// If we're active, we'll store our state in the hash as "#ppixiv/...".  The start of
// the hash will always be "#ppixiv", so we can tell it's our data.  If we're on a page
// where we're inactive by default, this also remembers that we've been activated.
//
// If we're inactive on a page where we're active by default, we'll always put something
// other than "#ppixiv" in the address bar.  It doesn't matter what it is.  This remembers
// that we were deactivated, and remains deactivated even if the user clicks an anchor
// in the page that changes the hash.
//
// If we become active or inactive after the page loads, we refresh the page.
//
// We have two sets of query parameters: args stored in the URL query, and args stored in
// the hash.  For example, in:
//
// https://www.pixiv.net/bookmark.php?p=2#ppixiv?illust_id=1234
//
// our query args are p=2, and our hash args are illust_id=1234.  We use query args to
// store state that exists in the underlying page, and hash args to store state that
// doesn't, so the URL remains valid for the actual Pixiv page if our UI is turned off.

class page_manager
{
    constructor()
    {
        this.window_popstate = this.window_popstate.bind(this);
        window.addEventListener("popstate", this.window_popstate, true);

        this.active = this._active_internal();
    };

    // Return the singleton, creating it if needed.
    static singleton()
    {
        if(page_manager._singleton == null)
            page_manager._singleton = new page_manager();
        return page_manager._singleton;
    };

    // Disable us, reloading the page to display it normally.
    disable()
    {
        document.location.hash = "no-ppixiv";
    };

    // Enable us, reloading the page if needed.
    enable()
    {
        document.location.hash = "ppixiv";
    };

    // Return the data source for a URL, or null if the page isn't supported.
    get_data_source_for_url(url)
    {
        // url is usually document.location, which for some reason doesn't have .searchParams.
        var url = new unsafeWindow.URL(url);

        // Note that member_illust.php is both illustration pages (mode=medium&illust_id) and author pages (id=).
        if(url.pathname == "/member_illust.php" && url.searchParams.get("mode") == "medium")
            return data_source_current_illust;
        else if(url.pathname == "/member.php" && url.searchParams.get("id") != null)
            return data_source_artist;
        else if(url.pathname == "/member_illust.php" && url.searchParams.get("id") != null)
            return data_source_artist;
        else if(url.pathname == "/bookmark.php" && url.searchParams.get("type") == null)
            return data_source_bookmarks;
        else if(url.pathname == "/new_illust.php" || url.pathname == "/new_illust_r18.php")
            return data_source_new_illust;
        else if(url.pathname == "/bookmark_new_illust.php")
            return data_source_bookmarks_new_illust;
        else if(url.pathname == "/search.php")
            return data_source_search;
        else if(url.pathname == "/discovery")
            return data_source_discovery;
        else if(url.pathname == "/bookmark_detail.php")
            return data_source_related_illusts;
        else if(url.pathname == "/ranking.php")
            return data_source_rankings;
        else
            return null;
    };

    // Return true if it's possible for us to be active on this page.
    available()
    {
        // We support the page if it has a data source.
        return this.get_data_source_for_url(document.location) != null;
    };

    window_popstate(e)
    {
        var currently_active = this._active_internal();
        if(this.active != currently_active)
        {
            // Stop propagation, so other listeners don't see this.  For example, this prevents
            // the thumbnail viewer from turning on or off as a result of us changing the hash
            // to "#no-ppixiv".
            e.stopImmediatePropagation();

            console.log("Active state changed");

            // The URL has changed and caused us to want to activate or deactivate.  Reload the
            // page.
            //
            // We'd prefer to reload with cache, like a regular navigation, but Firefox seems
            // to reload without cache no matter what we do, even though document.location.reload
            // is only supposed to bypass cache on reload(true).  There doesn't seem to be any
            // reliable workaround.
            document.location.reload();
        }
    };

    // Return true if we're active by default on the current page.
    active_by_default()
    {
        return this.available();
    };

    // Return true if we're currently active.
    //
    // This is cached at the start of the page and doesn't change unless the page is reloaded.
    _active_internal()
    {
        // If the hash is empty, use the default.
        if(document.location.hash == "")
            return this.active_by_default();

        // If we have a hash and it's not #ppixiv, then we're explicitly disabled.  If we
        // # do have a #ppixiv hash, we're explicitly enabled.
        return this.parse_hash() != null;
    };

    // Parse our data out of the hash, returning a URL.  If the hash isn't one of ours,
    // return null.
    parse_hash()
    {
        var ppixiv_url = document.location.hash.startsWith("#ppixiv");
        if(!ppixiv_url)
            return null;

        // Parse the hash of the current page as a path.  For example, if
        // the hash is #ppixiv/foo/bar?baz, parse it as /ppixiv/foo/bar?baz.
        var adjusted_url = document.location.hash.replace(/#/, "/");
        return new URL(adjusted_url, window.location);
    };

    // Get the arguments stored in the URL hash.
    get_hash_args()
    {
        var url = this.parse_hash();
        if(url == null)
            return new unsafeWindow.URLSearchParams();

        var query = url.search;
        if(!query.startsWith("?"))
            return new unsafeWindow.URLSearchParams();

        query = query.substr(1);

        // Use unsafeWindow.URLSearchParams to work around https://bugzilla.mozilla.org/show_bug.cgi?id=1414602.
        var params = new unsafeWindow.URLSearchParams(query);
        return params;
    };

    // Get the arguments stored in the URL query.
    get_query_args()
    {
        // Why is there no searchParams on document.location?
        return new unsafeWindow.URL(document.location).searchParams;
    }

    // Update the URL.  If add_to_history is true, add a new history state.  Otherwise,
    // replace the current one.
    set_args(query_params, hash_params, add_to_history)
    {
        var url = new URL(document.location);
        url.search = query_params.toString();
        url.hash = "#ppixiv";
        var hash_string = hash_params.toString();
        if(hash_string != "")
            url.hash += "?" + hash_string;

        // console.log("Changing state to", url.toString());
        if(add_to_history)
            history.pushState(null, "", url.toString());
        else
            history.replaceState(null, "", url.toString());
    }

    // Given a list of tags, return the URL to use to search for them.  This differs
    // depending on the current page.
    get_url_for_tag_search(tags)
    {
        var url = new URL(document.location);

        if(url.pathname == "/search.php")
        {
            // If we're on search already, preserve other settings so we just change the
            // search tag.  Just remove the page number.
            url.searchParams.delete("p");
        } else {
            // If we're not, change to search and remove the rest of the URL.
            url = new URL("/search.php#ppixiv", document.location);
        }
        
        url.searchParams.set("word", tags);
        return url;
    }
}

// Fix Pixiv's annoying link interstitials.
//
// External links on Pixiv go through a pointless extra page.  This seems like
// they're trying to mask the page the user is coming from, but that's what
// rel=noreferrer is for.  Search for these links and fix them.
//
// This also removes target=_blank, which is just obnoxious.  If I want a new
// tab I'll middle click.
(function() {
    // Ignore iframes.
    if(window.top != window.self)
        return;
    
    var observer = new window.MutationObserver(function(mutations) {
        for(var mutation of mutations) {
            if(mutation.type != 'childList')
                return;

            for(var node of mutation.addedNodes)
            {
                if(node.querySelectorAll == null)
                    continue;

                helpers.fix_pixiv_links(node);
            }
        }
    });

    window.addEventListener("DOMContentLoaded", function() {
        helpers.fix_pixiv_links(document.body);

        observer.observe(window.document.body, {
            // We could listen to attribute changes so we'll fix links that have their
            // target changed after they're added to the page, but unless there are places
            // where that's needed, let's just listen to node additions so we don't trigger
            // too often.
            attributes: false,        
            childList: true,
            subtree: true
        });
    }, true);
})();

// Handle preloading images.
//
// If we have a reasonably fast connection and the site is keeping up, we can just preload
// blindly and let the browser figure out priorities.  However, if we preload too aggressively
// for the connection and loads start to back up, it can cause image loading to become delayed.
// For example, if we preload 100 manga page images, and then back out of the page and want to
// view something else, the browser won't load anything else until those images that we no
// longer need finish loading.
//
// image_preloader is told the illust_id that we're currently showing, and the ID that we want
// to speculatively load.  We'll run loads in parallel, giving the current image's resources
// priority and cancelling loads when they're no longer needed.
//
// This doesn't handle thumbnail preloading.  Those are small and don't really need to be
// cancelled, and since we don't fill the browser's load queue here, we shouldn't prevent
// thumbnails from being able to load.

// A base class for fetching a single resource:
class _preloader
{
    constructor()
    {
        this._run_callback = this._run_callback.bind(this);
    }

    // Call and clear this.callback.
    _run_callback()
    {
        if(this.callback == null)
            return;

        var cb = this.callback;
        this.callback = null;
        try {
            cb(this);
        } catch(e) {
            console.error(e);
        }
    }
}

// Load a single image with <img>:
class _img_preloader extends _preloader
{
    constructor(url)
    {
        super();
        this.url = url;
    }

    // Start the fetch.  This should only be called once.  callback will be called when the fetch
    // completes (it won't be called if it's cancelled first).
    start(callback)
    {
        this.callback = callback;

        this.img = document.createElement("img");
        this.img.src = this.url;

        // If the image loaded synchronously, run the callbnack asynchronously.  Otherwise,
        // call it when the image finishes loading.
        if(this.img.complete)
            setTimeout(this._run_callback, 0);
        else
            this.img.addEventListener("load", this._run_callback);
    }

    // Cancel the fetch.
    cancel()
    {
        // Setting the src of an img causes any ongoing fetch to be cancelled in both Firefox
        // and Chrome.  Set it to a transparent PNG (if we set it to "#", Chrome will try to
        // load the page URL as an image).
        if(this.img == null)
            return;

        this.img.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
        this.img = null;
        this.callback = null;
    }
}

// Load a resource with XHR.  We rely on helpers.fetch_resource to make concurrent
// loads with zip_image_player work cleanly.
class _xhr_preloader extends _preloader
{
    constructor(url)
    {
        super();
        this.url = url;
    }

    start(callback)
    {
        this.callback = callback;

        this.xhr = helpers.fetch_resource(this.url, {
            onload: this._run_callback,
        });
    }

    cancel()
    {
        if(this.xhr == null)
            return;

        this.xhr.abort();
        this.xhr = null;
    }
}

// The image preloader singleton.
class image_preloader
{
    // Return the singleton, creating it if needed.
    static get singleton()
    {
        if(image_preloader._singleton == null)
            image_preloader._singleton = new image_preloader();
        return image_preloader._singleton;
    };

    constructor()
    {
        this.preload_completed = this.preload_completed.bind(this);

        // The _preloader objects that we're currently running.
        this.preloads = [];

        // A queue of URLs that we've finished preloading recently.  We use this to tell if
        // we don't need to run a preload.
        this.recently_preloaded_urls = [];
    }

    // Set the illust_id the user is currently viewing.  If illust_id is null, the user isn't
    // viewing an image (eg. currently viewing thumbnails).
    set_current_image(illust_id)
    {
        if(this.current_illust_id == illust_id)
            return;

        this.current_illust_id = illust_id;
        this.current_illust_info = null;
        if(this.current_illust_id == null)
            return;

        // Get the image data.  This will often already be available.
        image_data.singleton().get_image_info(this.current_illust_id, function(illust_info)
        {
            if(this.current_illust_id != illust_id || this.current_illust_info != null)
                return;

            // Store the illust_info for current_illust_id.
            this.current_illust_info = illust_info;

            // Preload thumbnails.
            this.preload_thumbs(illust_info);

            this.check_fetch_queue();

        }.bind(this));
    }

    // Set the illust_id we want to speculatively load, which is the next or previous image in
    // the current search.  If illust_id is null, we don't want to speculatively load anything.
    set_speculative_image(illust_id)
    {
        if(this.speculative_illust_id == illust_id)
            return;
        
        this.speculative_illust_id = illust_id;
        this.speculative_illust_info = null;
        if(this.speculative_illust_id == null)
            return;

        // Get the image data.  This will often already be available.
        image_data.singleton().get_image_info(this.speculative_illust_id, function(illust_info)
        {
            if(this.speculative_illust_id != illust_id || this.speculative_illust_info != null)
                return;

            // Store the illust_info for current_illust_id.
            this.speculative_illust_info = illust_info;

            // Preload thumbnails.
            this.preload_thumbs(illust_info);

            this.check_fetch_queue();
        }.bind(this));
    }

    // See if we need to start or stop preloads.  We do this when we have new illustration info,
    // and when a fetch finishes.
    check_fetch_queue()
    {
        // console.log("check queue:", this.current_illust_info != null, this.speculative_illust_info != null);

        // Make a list of fetches that we want to be running, in priority order.
        var wanted_preloads = [];
        if(this.current_illust_info != null)
            wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.current_illust_info));
        if(this.speculative_illust_info != null)
            wanted_preloads = wanted_preloads.concat(this.create_preloaders_for_illust(this.speculative_illust_info));

        // Remove all preloads from wanted_preloads that we've already finished recently.
        var filtered_preloads = [];
        for(var preload of wanted_preloads)
        {
            if(this.recently_preloaded_urls.indexOf(preload.url) == -1)
                filtered_preloads.push(preload);
        }

        // If we don't want any preloads, stop.  If we have any running preloads, let them continue.
        if(filtered_preloads.length == 0)
        {
            // console.log("Nothing to do");
            return;
        }

        // Discard preloads beyond the number we want to be running.  If we're loading more than this,
        // we'll start more as these finish.
        var concurrent_preloads = 5;
        filtered_preloads.splice(concurrent_preloads);
        // console.log("Preloads:", filtered_preloads.length);

        // Start preloads that aren't running.  Add all preloads that are now running to
        // updated_preload_list.
        var unwanted_preloads;
        var updated_preload_list = [];
        for(var preload of filtered_preloads)
        {
            // If we already have a preloader running for this URL, just let it continue.
            var active_preload = this._find_active_preload_by_url(preload.url);
            if(active_preload != null)
            {
                updated_preload_list.push(active_preload);
                continue;
            }

            // Start this preload.
            // console.log("Start preload:", preload.url);
            preload.start(this.preload_completed);
            updated_preload_list.push(preload);
        }

        // Cancel preloads in this.preloads that aren't in updated_preload_list.  These are
        // preloads that we either don't want anymore, or which have been pushed further down
        // the priority queue and overridden.
        for(var preload of this.preloads)
        {
            if(updated_preload_list.indexOf(preload) != -1)
                continue;

            console.log("Cancelling preload:", preload.url);
            preload.cancel();
        }

        this.preloads = updated_preload_list;
    }

    // This is called when a preloader finishes loading.
    preload_completed(preload)
    {
        // preload finished running.  Remove it from this.preload and add its URL to recently_preloaded_urls.
        this.recently_preloaded_urls.push(preload.url);
        this.recently_preloaded_urls.splice(1000);

        var idx = this.preloads.indexOf(preload);
        if(idx == -1)
        {
            console.error("Preload finished, but we weren't running it:", preload.url);
            return;
        }
        this.preloads.splice(idx, 1);

        // See if we need to start another preload.
        this.check_fetch_queue();
    }

    // Return the preloader if we're currently preloading url.
    _find_active_preload_by_url(url)
    {
        for(var preload of this.preloads)
            if(preload.url == url)
                return preload;
        return null;
    }

    // Return an array of preloaders to load resources for the given illustration.
    create_preloaders_for_illust(illust_data)
    {
        // Don't precache muted images.
        if(main.any_tag_muted(illust_data.tags.tags))
            return [];
        if(main.is_muted_user_id(illust_data.userId))
            return [];

        // If this is a video, preload the ZIP.
        if(illust_data.illustType == 2)
        {
            var results = [];
            results.push(new _xhr_preloader(illust_data.ugoiraMetadata.originalSrc));

            // Preload the original image too, which viewer_ugoira displays if the ZIP isn't
            // ready yet.
            results.push(new _img_preloader(illust_data.urls.original));

            return results;
        }

        // Otherwise, preload the images.  Preload thumbs first, since they'll load
        // much faster.
        var results = [];
        for(var page = 0; page < illust_data.pageCount; ++page)
        {
            var url = helpers.get_url_for_page(illust_data, page, "original");
            results.push(new _img_preloader(url));
        }

        return results;
    }

    preload_thumbs(illust_info)
    {
        // We're only interested in preloading thumbs for manga pages for the manga
        // thumbnail bar.
        if(illust_info.pageCount < 2)
            return;

        // Preload thumbs directly rather than queueing, since they load quickly and
        // this reduces flicker in the manga thumbnail bar.
        var thumbs = [];
        for(var page = 0; page < illust_info.pageCount; ++page)
            thumbs.push(helpers.get_url_for_page(illust_info, page, "thumb"));

        helpers.preload_images(thumbs);
    }
};

var debug_show_ui = false;

// This runs first and sets everything else up.
class main_controller
{
    constructor()
    {
        // Early initialization.  This happens before anything on the page is loaded, since
        // this script runs at document-start.
        //
        // If this is an iframe, don't do anything.  This may be a helper iframe loaded by
        // load_data_in_iframe, in which case the main page will do the work.
        if(window.top != window.self)
            return;

        this.dom_content_loaded = this.dom_content_loaded.bind(this);

        // Create the page manager.
        page_manager.singleton();

        this.early_setup();

        window.addEventListener("DOMContentLoaded", this.dom_content_loaded, true);
    }

    // When we're disabled, but available on the current page, add the button to enable us.
    setup_disabled_ui()
    {
        // Create the activation button.
        var disabled_ui = helpers.create_node(resources['disabled.html']);
        helpers.add_style('.ppixiv-disabled-ui > a { background-image: url("' + binary_data['activate-icon.png'] + '"); };');
        document.body.appendChild(disabled_ui);
    };

    temporarily_hide_document()
    {
        if(document.documentElement != null)
        {
            document.documentElement.hidden = true;
            return;
        }

        // At this point, none of the document has loaded, and document.body and
        // document.documentElement don't exist yet, so we can't hide it.  However,
        // we want to hide the document as soon as it's added, so we don't flash
        // the original page before we have a chance to replace it.  Use a mutationObserver
        // to detect the document being created.
        var observer = new MutationObserver(function(mutation_list) {
            if(document.documentElement == null)
                return;
            observer.disconnect();

            document.documentElement.hidden = true;
        });

        observer.observe(document, { attributes: false, childList: true, subtree: true });
    };

    // This is called when we're enabled at the start of page load.
    early_setup()
    {
        if(!page_manager.singleton().active)
            return;

        // Try to prevent site scripts from running, since we don't need any of it.
        if(navigator.userAgent.indexOf("Firefox") != -1)
            helpers.block_all_scripts();

        this.temporarily_hide_document();
        install_polyfills();
        helpers.block_network_requests();
    };

    dom_content_loaded(e)
    {
        try {
            this.setup();
        } catch(e) {
            // GM error logs don't make it to the console for some reason.
            console.log(e);
        }
    }

    // This is called on DOMContentLoaded (whether we're active or not).
    setup()
    {
        // If we're not active, stop without doing anything and leave the page alone.
        if(!page_manager.singleton().active)
        {
            // If we're disabled and can be enabled on this page, add the button.
            if(page_manager.singleton().available())
                this.setup_disabled_ui();
            
            return;
        }

        // Try to init using globalInitData if possible.
        var data = helpers.get_global_init_data(document);
        if(data != null)
        {
            this.init_global_data(data.token, data.userData.id, data.premium && data.premium.popularSearch, data.mute);

            // If data is available, this is a newer page with globalInitData.
            // This can have one or more user and/or illust data, which we'll preload
            // so we don't need to fetch it later.
            for(var preload_illust_id in data.preload.illust)
                image_data.singleton().add_illust_data(data.preload.illust[preload_illust_id]);

            for(var preload_user_id in data.preload.user)
                image_data.singleton().add_user_data(data.preload.user[preload_user_id]);
        }
        else
        {
            // If that's not available, this should be an older page with the "pixiv" object.
            var pixiv = helpers.get_pixiv_data(document);
            if(pixiv == null)
            {
                // If we can't find either, either we're on a page we don't understand or we're
                // not logged in.  Stop and let the page run normally.
                console.log("Couldn't find context data.  Are we logged in?");
                document.documentElement.hidden = false;
                return;
            }
            this.init_global_data(pixiv.context.token, pixiv.user.id, pixiv.user.premium, pixiv.user.mutes);
        }

        console.log("Starting");

        // Remove everything from the page and move it into a dummy document.
        var html = document.createElement("document");
        helpers.move_children(document.head, html);
        helpers.move_children(document.body, html);

        // Now that we've cleared the document, we can unhide it.
        document.documentElement.hidden = false;

        // Get the data source class for this page.
        var data_source_class = page_manager.singleton().get_data_source_for_url(document.location);
        if(data_source_class == null)
        {
            console.error("Unexpected path:", document.location.pathname);
            return;
        }

        // Create the data source for this page, passing it the original page data.
        var source = new data_source_class(html);

        // Create the main UI.
        new main_ui(source);
    };

    init_global_data(csrf_token, user_id, premium, mutes)
    {
        var muted_tags = [];
        var muted_user_ids = [];
        for(var mute of mutes)
        {
            if(mute.type == 0)
                muted_tags.push(mute.value);
            else if(mute.type == 1)
                muted_user_ids.push(mute.value);
        }
        this.muted_tags = muted_tags;
        this.muted_user_ids = muted_user_ids;

        window.global_data = {
            // Store the token for XHR requests.
            csrf_token: csrf_token,
            user_id: user_id,
        };

        // Set the .premium class on body if this is a premium account, to display features
        // that only work with premium.
        //
        // It would make more sense to do this in main_ui, but user data comes in different
        // forms for different pages and it's simpler to just do it here.
        helpers.set_class(document.body, "premium", premium);
    };

    is_muted_user_id(user_id, tags)
    {
        return this.muted_user_ids.indexOf(user_id) != -1;
            return true;
        return false;
    };

    // Return true if any tag in tag_list is muted.
    any_tag_muted(tag_list)
    {
        for(var tag of tag_list)
        {
            if(tag.tag)
                tag = tag.tag;
            if(this.muted_tags.indexOf(tag) != -1)
                return tag;
        }
        return null;
    }
};

var main = new main_controller();

})();