// ==UserScript== // @name Usability Tweaks for Manga sites. // @namespace Itsnotlupus Industries // @match https://asura.gg/* // @match https://flamescans.org/* // @match https://void-scans.com/* // @match https://luminousscans.com/* // @match https://shimascans.com/* // @match https://nightscans.org/* // @match https://freakscans.com/* // @match https://mangastream.themesia.com/* // @noframes // @version 1.9 // @author Itsnotlupus // @license MIT // @description Keyboard navigation, inertial drag scrolling, chapter preloading and chapter tracking for MangaStream sites, like Asura Scans, Flame Scans, Void Scans, Luminous Scans, Shima Scans, Night Scans, Freak Scans. // @run-at document-start // @require https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js // @grant GM_setValue // @grant GM_getValue // @downloadURL none // ==/UserScript== /* not currently supported, but might later: realmscans.xyz manhwafreak.com manhwafreak-fr.com */ /* jshint esversion:11 */ /* eslint curly: 0 no-return-assign: 0, no-loop-func: 0 */ /* global fixConsole, addStyles, $, $$, $$$, events, rAF, observeDOM, untilDOM, until, fetchHTML, prefetch, crel */ // fixConsole(); // TODO: reorganize this mess somehow. addStyles(` /* remove ads and blank space between images were ads would have been */ [class^="ai-viewport"], .code-block, .blox, .kln, [id^="teaser"] { display: none !important; } /* hide various header and footer content. */ .socialts, .chdesc, .chaptertags, .postarea >#comments, .postbody>article>#comments { display: none; } /* asura broke some of MangaStream's CSS. whatever. */ .black #thememode { display: none } /* style a custom button to expand collapsed footer areas */ button.expand { float: right; border: 0; border-radius: 20px; padding: 2px 15px; font-size: 13px; line-height: 25px; background: #333; color: #888; font-weight: bold; cursor: pointer; } button.expand:hover { background: #444; } /* disable builtin drag behavior to allow drag scrolling */ * { user-select: none; -webkit-user-drag: none; } body.drag { cursor: grabbing !important; } /* support mouse swiping to navigate between chapters */ #readerarea { position: relative; } .swipe { position: fixed; top: calc( 50% - 20px ); right: calc( 50% - 100px ); pointer-events: none; opacity: 0; padding: 4px 30px; border-radius: 40px; background: #913fe2; color: #fff; font-size: 26px; font-weight: bold; white-space: nowrap; } /* add a badge on bookmark items showing the number of unread chapters */ .unread-badge { position: absolute; top: 0; right: 0; z-index: 9999; display: block; padding: 2px; margin: 5px; border: 1px solid #0005b1; border-radius: 12px; background: #ffc700; color: #0005b1; font-weight: bold; font-family: cursive; transform: rotate(10deg); width: 24px; height: 24px; line-height: 18px; text-align: center; } .soralist .unread-badge { position: initial; display: inline-block; zoom: 0.8; } /* luminousscans junk */ .flame, .flamewidthwrap { display: none !important } .listupd .bs .bsx .limit .type { right: initial; left: 5px } `); // keyboard navigation. good for long strips, which is apparently all this site has. const prev = () => $`.ch-prev-btn`?.click(); const next = () => $`.ch-next-btn`?.click(); addEventListener('keydown', e => document.activeElement.tagName != 'INPUT' && !e.repeat && ({ ArrowLeft: prev, ArrowRight: next, KeyA: prev, KeyD: next, KeyK: toggleLandscape.bind(null, -1), KeyL: toggleLandscape.bind(null, 1), }[e.code]?.()), true); // inertial drag scrolling and swipe navigation let landscape = false; // false=portrait, 1=90egd, 2=270deg let [ delta, drag, dragged, navigating, navPos ] = [0, false, false, false, 0]; let previousTouch; const eventListeners = { // wheel event, used to handle landscape scrolling f(1) = -1, f(2) = 1 => f(x) = x*2-3 wheel: e => landscape && scrollBy(e.wheelDeltaY * (landscape*2-3), 0), // mouse dragging/swiping mousedown: () => { [ delta, drag, dragged, navigating, navPos ] = [0, true, false, false, 0]; }, mousemove: e => { if (drag) { if (!navigating) { if (landscape) { scrollBy(delta=-e.movementX, 0); } else { scrollBy(0, delta=-e.movementY); } } else { delta = 0; } if (!landscape) { navPos+=e.movementX; if (navigating) { // show some resistance before swiping. const width = $`#readerarea`.clientWidth; const actualPos = Math.min(Math.max(navPos, -width/2), width/2); const readerArea = $`#readerarea`, swipeLabels = $$`.swipe`; readerArea.style.transform = `translate(${actualPos}px)`; swipeLabels[~~(navPos<0)].style.opacity = '1'; swipeLabels[~~(navPos>0)].removeAttribute('style'); } } if (Math.abs(delta)>3) { dragged = true; document.body.classList.add('drag'); $`#readerarea`.style.transform = "translate(0px)"; } else if (Math.abs(navPos)>100) { navigating = true; document.body.classList.add('drag'); } } }, mouseup: () => { if (drag) { drag=false; rAF((_, next) => Math.abs(delta*=0.98)>1 && next(landscape?scrollBy(delta,0):scrollBy(0, delta))); } if (dragged) { dragged = false; document.body.classList.remove('drag'); const preventClick = e => { e.preventDefault(); e.stopPropagation(); removeEventListener('click', preventClick, true); }; addEventListener('click', preventClick, true); } if (navigating) { navigating = false; document.body.classList.remove('drag'); const width = $`#readerarea`.clientWidth; if (navPos > width/4) prev(); else if (navPos < -width/4) next(); else { $`#readerarea`.style = "transition: all .5s; transform: translate(0px)"; $$`.swipe`.forEach(swipe=>swipe.removeAttribute('style')); setTimeout(()=> $`#readerarea`.style = "", 600); } } }, // adapters for touch events (mobile etc.) touchstart: () => { eventListeners.mousedown(); }, touchmove: e => { if (e.touches.length === 1) { const [touch] = e.touches; e.movementX = previousTouch ? touch.pageX - previousTouch.pageX : 0; e.movementY = 0; // don't fight native mobile scrolling previousTouch = touch; eventListeners.mousemove(e); } }, touchend: () => { previousTouch = null; eventListeners.mouseup(); } }; events(eventListeners); // add swipe indicators untilDOM("body").then(body=>body.prepend(crel("div", { className: "swipe swipe-left", innerHTML: ` Previous Chapter` }), crel("div", { className: "swipe swipe-right", innerHTML: ` Next Chapter ` }) )); // don't be shy about loading an entire chapter untilDOM(()=>$$`img[loading="lazy"]`).then(images=>images.forEach(img => img.loading="eager")); // retry loading broken images const imgBackoff = new Map(); const imgNextRetry = new Map(); const retryImage = img => { console.log("RETRY LOADING IMAGE! ",img.src); const now = Date.now(); const nextRetry = imgNextRetry.has(img) ? imgNextRetry.get(img) : (imgNextRetry.set(img, now),now); if (nextRetry <= now) { // exponential backoff between retries: 0ms, 250ms, 500ms, 1s, 2s, 4s, 8s, 10s, 10s, ... imgBackoff.set(img, Math.min(10000,(imgBackoff.get(img)??125)*2)); imgNextRetry.set(img, now + imgBackoff.get(img)); img.src=img.src; } else { setTimeout(()=>retryImage(img), nextRetry - now); } } events({ load() { observeDOM(() => { [...document.images].filter(img=>img.complete && !img.naturalHeight).forEach(retryImage); }); }}); // and prefetch the next chapter's images for even less waiting. untilDOM(`a.ch-next-btn[href^="http"]`).then(a => fetchHTML(a.href).then(doc => [...doc.images].forEach(img => prefetch(img.src)))); // have bookmarks track the last chapter you read // NOTE: If you use TamperMonkey, you can use their "Utilities" thingy to export/import this data across browsers/devices // (I wish this was an automatic sync tho.) const LAST_READ_CHAPTER_KEY = `${location.hostname}/lastReadChapterKey`; const SERIES_ID_HREF_MAP = `${location.hostname}/seriesIdHrefMap`; const SERIES_ID_LATEST_MAP = `${location.hostname}/seriesIdLatestMap`; const BOOKMARK = `${location.hostname}/bookmark`; const BOOKMARK_HTML = `${location.hostname}/bookmarkHTML`; // backward-compatibility - going away soon. const X_LAST_READ_CHAPTER_KEY = "lastReadChapter"; const X_SERIES_ID_HREF_MAP = "seriesIdHrefMap"; const X_SERIES_ID_LATEST_MAP = "seriesIdLatestMap"; const lastReadChapters = GM_getValue(LAST_READ_CHAPTER_KEY, GM_getValue(X_LAST_READ_CHAPTER_KEY, JSON.parse(localStorage.getItem(X_LAST_READ_CHAPTER_KEY) ?? "{}"))); const seriesIdHrefMap = GM_getValue(SERIES_ID_HREF_MAP, GM_getValue(X_SERIES_ID_HREF_MAP, JSON.parse(localStorage.getItem(X_SERIES_ID_HREF_MAP) ?? "{}"))); const seriesIdLatestMap = GM_getValue(SERIES_ID_LATEST_MAP, GM_getValue(X_SERIES_ID_LATEST_MAP, JSON.parse(localStorage.getItem(X_SERIES_ID_LATEST_MAP) ?? "{}"))); // sync site bookmarks into userscript data. // rules: // 1. A non-empty usBookmarks is always correct on start. // 2. any changes to localStorage while the page is loaded updates usBookmarks. const usBookmarks = GM_getValue(BOOKMARK, GM_getValue('bookmark', [])); if (usBookmarks.length) { localStorage.bookmark = JSON.stringify(usBookmarks); } else { GM_setValue(BOOKMARK, JSON.parse(localStorage.bookmark ?? '[]')); } (async function watchBookmarks() { let lsb = localStorage.bookmark; while (true) { await until(() => lsb !== localStorage.bookmark); lsb = localStorage.bookmark; GM_setValue(BOOKMARK, JSON.parse(lsb)); } })(); function getLastReadChapter(post_id, defaultValue = {}) { return lastReadChapters[post_id] ?? defaultValue; } function setLastReadChapter(post_id, chapter_id, chapter_number) { lastReadChapters[post_id] = { id: chapter_id, number: chapter_number }; GM_setValue(LAST_READ_CHAPTER_KEY, lastReadChapters); } function getSeriesId(post_id, href) { if (post_id) { seriesIdHrefMap[href] = post_id; GM_setValue(SERIES_ID_HREF_MAP, seriesIdHrefMap); } else { post_id = seriesIdHrefMap[href]; } return post_id; } function getLatestChapter(post_id, chapter) { if (chapter) { seriesIdLatestMap[post_id] = chapter; GM_setValue(SERIES_ID_LATEST_MAP, seriesIdLatestMap); } else { chapter = seriesIdLatestMap[post_id]; } return chapter; } // new UI elements function makeCollapsedFooter({ label, section }) { const elt = crel('div', { className: 'bixbox', style: 'padding: 8px 15px' }, crel('button', { className: 'expand', textContent: label, onclick() { section.style.display = 'block'; elt.style.display = 'none'; } })); section.parentElement.insertBefore(elt, section); } // series card decorations, used in bookmarks and manga lists pages. const CHAPTER_REGEX = /\bChapter (?\d+)\b|\bch.(?\d+)\b/i; async function decorateCards(reorder = true) { const cards = await untilDOM(() => $$$("//div[contains(@class, 'listupd')]//div[contains(@class, 'bsx')]/..")); cards.reverse().forEach(b => { const post_id = getSeriesId(b.firstElementChild.dataset.id, $('a', b).href); const epxs = $('.epxs',b)?.textContent ?? b.innerHTML.match(/
(?.*?)<\/div>/)?.groups.epxs; const latest_chapter = getLatestChapter(post_id, parseInt(epxs?.match(CHAPTER_REGEX)?.groups.chapter)); const { number, id } = getLastReadChapter(post_id); if (id) { const unreadChapters = latest_chapter - number; if (unreadChapters) { // reorder bookmark, link directly to last read chapter and slap an unread count badge. if (reorder) b.parentElement.prepend(b); $('a',b).href = '/?p=' + id; $('.limit',b).prepend(crel('div', { className: 'unread-badge', textContent: unreadChapters<100 ? unreadChapters : '💀', title: `${unreadChapters} unread chapter${unreadChapters>1?'s':''}` })) } else { // nothing new to read here. gray it out. b.style = 'filter: grayscale(70%);opacity:.9'; } } else { // we don't have data on that series. leave it alone. } }); } // text-mode /manga/ page. put badges at the end of each series title, and strike through what's already read. async function decorateText() { const links = await untilDOM(()=>$`.soralist a.series` && $$`.soralist a.series`); links.forEach(a => { const post_id = getSeriesId(a.rel, a.href); const latest_chapter = getLatestChapter(post_id); const { number, id } = getLastReadChapter(post_id); if (id) { const unreadChapters = latest_chapter - number; if (unreadChapters) { a.href = '/?p=' + id; a.append(crel('div', { className: 'unread-badge', textContent: unreadChapters<100 ? unreadChapters : '💀', title: `${unreadChapters} unread chapter${unreadChapters>1?'s':''}` })) } else { // nothing new to read here. gray it out. a.style = 'text-decoration: line-through;color: #777' } } }) } // page specific tweaks const chapterMatch = document.title.match(CHAPTER_REGEX); if (chapterMatch) { // We're on a chapter page. Save chapter number and id if greater than last saved chapter number. const chapter_number = parseInt(chapterMatch.groups.chapter ?? chapterMatch.groups.ch); const { post_id, chapter_id } = unsafeWindow; const { number = 0 } = getLastReadChapter(post_id); if (number { // We're on a bookmark page. Wait for them to load, then tweak them to point to last read chapter, and gray out the ones that are fully read so far. setTimeout(()=> { if (!$`#bookmark-pool [data-id]`) { // no data yet from bookmark API. show a fallback. $`#bookmark-pool`.innerHTML = GM_getValue(BOOKMARK_HTML, localStorage.bookmarkHTML ?? ''); // add a marker so we know this is just a cached rendering. $`#bookmark-pool [data-id]`.classList.add('cached'); // decorate what we have. decorateCards(); } }, 1000); // wait until we get bookmark markup from the server, not cached. await untilDOM("#bookmark-pool .bs:first-child [data-id]:not(.cached)"); // bookmarks' ajax API is flaky (/aggressively rate-limited) - mitigate. GM_setValue(BOOKMARK_HTML, $`#bookmark-pool`.innerHTML); decorateCards(); })(); else { // try generic decorations on any non-bookmark page decorateCards(false); decorateText(); } untilDOM(`#chapterlist`).then(() => { // Add a "Continue Reading" button on main series pages. const post_id = $`.bookmark`.dataset.id; const { number, id } = getLastReadChapter(post_id); // add a "Continue Reading" button for series we recognize if (id) { $`.lastend`.prepend(crel('div', { className: 'inepcx', style: 'width: 100%' }, crel('a', { href: '/?p=' + id }, crel('span', {}, 'Continue Reading'), crel('span', { className: 'epcur' }, 'Chapter ' + number)) )); } }); untilDOM(()=>$$$("//span[text()='Related Series' or text()='Similar Series']/../../..")[0]).then(related => { // Tweak footer content on any page that has them // 1. collapse related series. makeCollapsedFooter({label: 'Show Related Series', section: related}); related.style.display = 'none'; }); untilDOM("#comments").then(comments => { // 2. collapse comments. makeCollapsedFooter({label: 'Show Comments', section: comments}); }); // Add ability to render as landscape, since long form strips have to known to switch to it. // If I could wield transformOrigins correctly, I might be able to animate the rotations. That'd be nice. // should there be an upside-down mode too? that seems useless. function toggleLandscape(dir=1) { function to270deg() { document.body.style.transform=`rotate(270deg) translate(0,${document.body.scrollHeight/2-innerHeight/2}px)`; document.body.style.overflowY="hidden"; scrollTo(document.documentElement.scrollTop,document.body.scrollHeight/2-innerHeight/2); landscape = 1; } function from270deg() { const offset = document.documentElement.scrollLeft; document.body.style.transform=""; document.body.style.overflowY=""; scrollTo(0, offset); landscape = false; } function to90deg() { document.body.style.transform=`rotate(90deg) translate(0,${innerHeight/2-document.body.scrollHeight/2}px)`; document.body.style.overflowY="hidden"; scrollTo(document.body.scrollHeight-document.documentElement.scrollTop-innerWidth/2,document.body.scrollHeight/2-innerHeight/2); landscape = 2; } function from90deg() { const offset = document.documentElement.scrollLeft document.body.style.transform=""; document.body.style.overflowY=""; scrollTo(0, document.body.scrollHeight - offset - innerHeight); landscape = false; } switch (landscape) { case false: dir == 1 ? to270deg() : to90deg(); break; case 1: from270deg(); if (dir == 1) to90deg(); break; case 2: from90deg(); if (dir != 1) to270deg(); break; } }