NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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(); } } } })();