// ==UserScript==
// @name Marker's Derpibooru Image Preloader
// @description Preload previous/next images.
// @version 1.2.14
// @author Marker
// @license MIT
// @namespace https://github.com/marktaiwan/
// @homepageURL https://github.com/marktaiwan/Derpibooru-Image-Preloader
// @supportURL https://github.com/marktaiwan/Derpibooru-Image-Preloader/issues
// @include https://derpibooru.org/*
// @include https://trixiebooru.org/*
// @include https://www.derpibooru.org/*
// @include https://www.trixiebooru.org/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @inject-into content
// @noframes
// @require https://openuserjs.org/src/libs/mark.taiwangmail.com/Derpibooru_Unified_Userscript_UI_Utility.js?v1.2.2
// ==/UserScript==
(function () {
'use strict';
const config = ConfigManager(
'Image Preloader',
'markers_img_prefetcher',
'Image preloader for a better comic reading experience.'
);
// setting up custom API key input
const apiFieldset = config.addFieldset(
'Account API key',
'api_settings',
'In order to get the correct previous/next images that works with your site filter or "my:*" searches, your API key is needed to authenticate API requests to the site. You can find your API key in your account settings.'
);
if (apiFieldset.pageElement) {
const description = $('i', apiFieldset.pageElement);
description.innerHTML = description.innerHTML.replace('account settings', '<a href="/registration/edit" target="_blank">account settings</a>');
}
const apiSetting = apiFieldset.registerSetting({
title: 'Stored key:',
key: 'api_key',
type: 'text',
defaultValue: ''
});
config.deleteEntry('api_key'); // we only called registerSetting for the UI, we don't actually use this for storing the key
const apiInput = $('input', apiSetting);
apiInput.setAttribute('disabled', '');
apiInput.removeAttribute('data-entry-key');
apiInput.value = GM_getValue('api_key', '');
const apiLoad = document.createElement('a');
apiLoad.href = '#';
apiLoad.innerText = 'Load key';
apiLoad.addEventListener('click', e => {
e.preventDefault();
const key = window.prompt('Enter your API key:', '').trim();
if (!key) return;
GM_setValue('api_key', key);
apiInput.value = key;
});
const apiDelete = document.createElement('a');
apiDelete.href = '#';
apiDelete.innerText = 'Delete key';
apiDelete.addEventListener('click', e => {
e.preventDefault();
GM_deleteValue('api_key');
apiInput.value = '';
});
apiSetting.insertAdjacentElement('beforeEnd', document.createElement('br'));
apiSetting.insertAdjacentElement('beforeEnd', apiLoad);
apiSetting.insertAdjacentElement('beforeEnd', document.createElement('br'));
apiSetting.insertAdjacentElement('beforeEnd', apiDelete);
apiFieldset.registerSetting({
title: 'Enable API workaround',
key: 'api_fallback',
description: 'Uses an alternate method for getting previous/next images, enable this if you don\'t want the script to have your API key.',
type: 'checkbox',
defaultValue: true
});
// regular settings
config.registerSetting({
title: 'Start prefetch',
key: 'run-at',
type: 'dropdown',
defaultValue: 'document-idle',
selections: [
{text: 'when current page finishes loading', value: 'document-idle'},
{text: 'as soon as possible', value: 'document-end'}
]
});
const imageSelection = config.addFieldset(
'Preloaded images',
'selection_settings'
);
imageSelection.registerSetting({
title: 'Previous/next images',
key: 'get_sequential',
type: 'checkbox',
defaultValue: true
});
imageSelection.registerSetting({
title: 'Description',
key: 'get_description',
description: 'Preload applicable links found in the description.',
type: 'checkbox',
defaultValue: true
});
const versionFieldset = config.addFieldset(
'Image scaling',
'scaling_settings'
);
versionFieldset.registerSetting({
title: 'Download scaled version',
key: 'scaled',
description: 'This is the version you see when you first open a page. If you have \'Scale large images\' disabled in the site settings, this setting will load the full version instead.',
type: 'checkbox',
defaultValue: true
});
versionFieldset.registerSetting({
title: 'Download full resolution version',
key: 'fullres',
description: 'Turn this on to ensure that the full sized version is always loaded.',
type: 'checkbox',
defaultValue: true
});
config.registerSetting({
title: 'Turn off preloading after',
key: 'off_timer',
description: 'Automatically turn off the script after periods of inactivity.',
type: 'dropdown',
defaultValue: '600',
selections: [
{text: ' 5 minutes', value: '300'},
{text: '10 minutes', value: '600'},
{text: '20 minutes', value: '1200'}
]
});
const SCRIPT_ID = 'markers_img_prefetcher';
const RUN_AT_IDLE = (config.getEntry('run-at') == 'document-idle');
const WEBM_SUPPORT = MediaSource.isTypeSupported('video/webm; codecs="vp8, vp9, vorbis, opus"');
const addToLoadingQueue = (function () {
const MAX_CONNECTIONS = 4;
const fetchQueue = [];
let activeConnections = 0;
const loadingLimited = () => (activeConnections >= MAX_CONNECTIONS && MAX_CONNECTIONS != 0);
const enqueue = (uri) => fetchQueue.push(uri);
const dequeue = () => fetchQueue.shift();
const fileLoadHandler = () => {
--activeConnections;
update();
};
const loadFile = (fileURI) => {
const IS_VIDEO = (fileURI.endsWith('.webm') || fileURI.endsWith('.mp4'));
const ele = document.createElement(IS_VIDEO ? 'video' : 'img');
if (IS_VIDEO) {
ele.setAttribute('preload', 'auto');
ele.addEventListener('canplaythrough', fileLoadHandler, {once: true});
} else {
ele.addEventListener('load', fileLoadHandler, {once: true});
}
ele.src = fileURI;
++activeConnections;
};
const update = () => {
while (!loadingLimited()) {
const uri = dequeue();
if (uri !== undefined) {
loadFile(uri);
} else {
// queue is empty, end loop.
break;
}
}
};
return (uri) => {
if (!loadingLimited()) {
loadFile(uri);
} else {
enqueue(uri);
}
};
})();
function $(selector, parent = document) {
return parent.querySelector(selector);
}
function $$(selector, parent = document) {
return parent.querySelectorAll(selector);
}
function getQueryParameter(key) {
if (!window.location.search) return;
const array = window.location.search.substring(1).split('&');
for (let i = 0; i < array.length; i++) {
if (key == array[i].split('=')[0]) return array[i].split('=')[1];
}
}
/**
* Picks the appropriate image version for a given width and height
* of the viewport and the image dimensions.
*/
function selectVersion(imageWidth, imageHeight, imageSize, imageMime) {
const imageVersions = {
small: [320, 240],
medium: [800, 600],
large: [1280, 1024]
};
let viewWidth = document.documentElement.clientWidth;
let viewHeight = document.documentElement.clientHeight;
// load hires if that's what you asked for
if (JSON.parse(localStorage.getItem('serve_hidpi'))) {
viewWidth *= (window.devicePixelRatio || 1);
viewHeight *= (window.devicePixelRatio || 1);
}
if (viewWidth > 1024 && imageHeight > 1024 && imageHeight > 2.5 * imageWidth) {
// Treat as comic-sized dimensions..
return 'tall';
}
// Find a version that is larger than the view in one/both axes
for (let i = 0, versions = Object.keys(imageVersions); i < versions.length; ++i) {
const version = versions[i];
const dimensions = imageVersions[version];
const versionWidth = Math.min(imageWidth, dimensions[0]);
const versionHeight = Math.min(imageHeight, dimensions[1]);
if (versionWidth > viewWidth || versionHeight > viewHeight) {
return version;
}
}
// If the view is larger than any available version, display the original image.
//
// Sanity check to make sure we're not serving unintentionally huge assets
// all at once (where "huge" > 25 MiB). Videos are loaded in chunks so it
// doesn't matter too much there.
if (imageMime === 'video/webm' || imageSize <= 26214400) {
return 'full';
}
else {
return 'large';
}
}
function fetchSequentialId(url) {
return fetch(url, {credentials: 'same-origin'})
.then(response => response.json())
.then(json => (json.images.length) ? json.images[0] : null);
}
function fetchMeta(metaURI) {
return fetch(metaURI, {credentials: 'same-origin'})
.then(response => response.json())
.then(meta => {
// check response for 'duplicate_of' redirect
return (meta.duplicate_of === undefined)
? meta
: fetchMeta(`${window.location.origin}/api/v1/json/images/${meta.duplicate_of}`);
});
}
async function fetchFile(meta) {
// 'meta' could be an URI or an object
const metadata = (typeof meta == 'string')
? await fetchMeta(meta).then(response => response.image)
: meta;
if (meta === null || isEmpty(metadata)) return;
const version = selectVersion(metadata.width, metadata.height, metadata.size, metadata.mime_type);
const uris = metadata.representations;
const serve_webm = JSON.parse(localStorage.getItem('serve_webm'));
const get_fullres = config.getEntry('fullres');
const get_scaled = config.getEntry('scaled');
const site_scaling = (document.querySelector('#image_target, .image-target').dataset.scaled !== 'false');
const serveGifv = (metadata.mime_type == 'image/gif' && uris.webm !== undefined && serve_webm); // gifv: video clips masquerading as gifs
if (serveGifv) {
uris['full'] = uris[WEBM_SUPPORT ? 'webm' : 'mp4'];
}
// May I never have to untangle these two statemeants again
if (get_scaled && site_scaling && version !== 'full') {
addToLoadingQueue(uris[version]);
}
if (get_fullres || (get_scaled && (version === 'full' || !site_scaling))) {
addToLoadingQueue(uris['full']);
}
}
function initPrefetch() {
config.setEntry('last_run', Date.now());
const urlTemplate = (imageId, filterId, qualifier) => {
const queryParameters = [];
const queryString = getQueryParameter('q');
const apiKey = GM_getValue('api_key', '');
let searchQuery = `id.${qualifier}%3A${imageId}`; // '%3A' == ':'
if (queryString) searchQuery += `%2C+${queryString}`; // '%2C' == ','
if (apiKey) queryParameters.push(['key', apiKey.trim()]);
// reverse the sort order to get the first image greater than the current id
if (qualifier == 'gt') queryParameters.push(['sd', 'asc']);
queryParameters.push(
['per_page', '1'],
['filter_id', filterId],
['q', searchQuery]
);
return `${window.location.origin}/api/v1/json/search/images?${queryParameters.map(tuple => tuple.join('=')).join('&')}`;
};
const regex = new RegExp(
`^https?://(?:(?:www\\.)?(?:derpibooru\\.org|trixiebooru\\.org)|${window.location.hostname.replace(/\./g, '\\.')})/(?:images/)?(\\d{1,})(?:\\?|\\?.{1,}|/|\\.html)?(?:#.*)?$`
);
const description = $('.image-description__text');
const get_sequential = config.getEntry('get_sequential');
const get_description = config.getEntry('get_description');
if (config.getEntry('fullres')) {
// preload current image's full res version
const imageTarget = document.querySelector('#image_target, .image-target');
if (imageTarget.dataset.scaled !== 'false') fetchFile({
width: Number.parseInt(imageTarget.dataset.width),
height: Number.parseInt(imageTarget.dataset.height),
representations: JSON.parse(imageTarget.dataset.uris),
size: Number.parseInt(imageTarget.dataset.imageSize),
mime_type: imageTarget.dataset.mimeType,
});
}
if (get_sequential) {
const currentImageID = regex.exec(window.location.href)[1];
const filterId = $('.js-datastore').dataset.filterId;
if (!config.getEntry('api_fallback')) {
const next = urlTemplate(currentImageID, filterId, 'lt');
const prev = urlTemplate(currentImageID, filterId, 'gt');
fetchSequentialId(next).then(fetchFile);
fetchSequentialId(prev).then(fetchFile);
} else {
const next = $('.js-next').href;
const prev = $('.js-prev').href;
[next, prev].forEach(url => {
fetch(url, {credentials: 'same-origin'}).then(response => {
const matchImageId = regex.exec(response.url);
if (matchImageId) fetchFile(`${window.location.origin}/api/v1/json/images/${matchImageId[1]}`);
});
});
}
}
if (get_description && description !== null) {
for (const link of $$('a', description)) {
const matchImageId = regex.exec(link.href);
if (matchImageId !== null) {
const metaURI = `${window.location.origin}/api/v1/json/images/${matchImageId[1]}`;
fetchFile(metaURI);
}
}
}
}
function toggleSettings(event) {
event.stopPropagation();
if (event.currentTarget.classList.contains('disabled')) return;
const anchor = event.currentTarget;
const input = $('input', anchor);
const entryId = input.dataset.settingEntry;
const storedValue = config.getEntry(entryId);
if (anchor === event.target) {
input.checked = !input.checked;
}
if (input.checked !== storedValue) {
config.setEntry(entryId, input.checked);
}
}
function insertUI() {
const header = $('header.header');
const headerRight = $('.header__force-right', header);
const menuButton = document.createElement('div');
menuButton.classList.add('dropdown', 'header__dropdown', 'hide-mobile', `${SCRIPT_ID}__menu`);
menuButton.innerHTML = `
<a class="header__link" href="#" data-click-preventdefault="true">
<i class="${SCRIPT_ID}__icon fa ${config.getEntry('preload') ? 'fa-shipping-fast' : 'fa-truck'}"></i>
<span class="hide-limited-desktop"> Preloader </span>
<span data-click-preventdefault="true"><i class="fa fa-caret-down"></i></span>
</a>
<nav class="dropdown__content dropdown__content-right hide-mobile">
<a class="${SCRIPT_ID}__main-switch header__link"></a>
<a class="header__link ${SCRIPT_ID}__option">
<input type="checkbox" id="${SCRIPT_ID}--get_seq" data-setting-entry="get_sequential">
<label for="${SCRIPT_ID}--get_seq"> Previous/Next</label>
</a>
<a class="header__link ${SCRIPT_ID}__option">
<input type="checkbox" id="${SCRIPT_ID}--get_desc" data-setting-entry="get_description">
<label for="${SCRIPT_ID}--get_desc"> Description</label>
</a>
</nav>`;
// Attach event listeners
$(`.${SCRIPT_ID}__main-switch`, menuButton).addEventListener('click', (e) => {
e.preventDefault();
const scriptActive = config.getEntry('preload');
if (scriptActive) {
scriptOff();
} else {
scriptOn();
}
});
for (const option of $$(`.${SCRIPT_ID}__option`, menuButton)) {
option.addEventListener('click', toggleSettings);
}
updateUI(menuButton);
headerRight.insertAdjacentElement('afterbegin', menuButton);
}
function updateUI(ele) {
const menu = ele || $(`.${SCRIPT_ID}__menu`);
const icon = $(`.${SCRIPT_ID}__icon`, menu);
const mainSwitch = $(`.${SCRIPT_ID}__main-switch`, menu);
const options = $$(`.${SCRIPT_ID}__option`, menu);
const scriptActive = config.getEntry('preload');
if (mainSwitch.innerHTML == '') {
mainSwitch.innerHTML = `<i class="fa"></i><span> Turn ${scriptActive ? 'off' : 'on'}</span>`;
}
if (scriptActive) {
icon.classList.remove('fa-truck');
icon.classList.add('fa-shipping-fast');
$('i', mainSwitch).classList.remove('fa-toggle-off');
$('i', mainSwitch).classList.add('fa-toggle-on');
$('span', mainSwitch).innerText = ' Turn off';
for (const option of options) {
option.classList.remove('disabled');
$('input', option).disabled = false;
}
} else {
icon.classList.remove('fa-shipping-fast');
icon.classList.add('fa-truck');
$('i', mainSwitch).classList.remove('fa-toggle-on');
$('i', mainSwitch).classList.add('fa-toggle-off');
$('span', mainSwitch).innerText = ' Turn on';
for (const option of options) {
option.classList.add('disabled');
$('input', option).disabled = true;
}
}
for (const option of options) {
const input = $('input', option);
input.checked = config.getEntry(input.dataset.settingEntry);
}
}
function scriptOn() {
config.setEntry('preload', true);
updateUI();
initPrefetch();
}
function scriptOff() {
config.setEntry('preload', false);
updateUI();
}
function checkTimer() {
const lastRun = config.getEntry('last_run') || 0;
const offTimer = Number(config.getEntry('off_timer')) * 1000; // seconds => milliseconds
if (Date.now() - lastRun > offTimer) {
scriptOff();
}
}
function isEmpty(obj) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) return false;
}
return true;
}
// run on main image page, only start after the page finished loading resources
if (document.querySelector('#image_target, .image-target') !== null) {
// Use the storage event to update UI across tabs
window.addEventListener('storage', (function () {
let preload = config.getEntry('preload');
let get_sequential = config.getEntry('get_sequential');
let get_description = config.getEntry('get_description');
return function (e) {
if (e.key !== 'derpi_four_u') return;
const new_preload = config.getEntry('preload');
const new_get_sequential = config.getEntry('get_sequential');
const new_get_description = config.getEntry('get_description');
// check for changes in settings
if ((preload !== new_preload) || (get_sequential !== new_get_sequential) || (get_description !== new_get_description)) {
[preload, get_sequential, get_description] = [new_preload, new_get_sequential, new_get_description];
updateUI();
}
};
})());
insertUI();
checkTimer();
if (config.getEntry('preload')) {
if (document.readyState !== 'complete' && RUN_AT_IDLE) {
window.addEventListener('load', initPrefetch);
} else {
initPrefetch();
}
}
}
})();