// ==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.8
// @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
// ==/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,
KeyL: toggleLandscape,
}[e.code]?.()), true);
// inertial drag scrolling and swipe navigation
let landscape = false;
let [ delta, drag, dragged, navigating, navPos ] = [0, false, false, false, 0];
let previousTouch;
const eventListeners = {
// wheel event, used to handle landscape scrolling
wheel: e => landscape && scrollBy(-e.wheelDeltaY, 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);
}
}
if (!landscape) {
navPos+=e.movementX;
if (navigating) { // show some resistance before swiping.
const readerArea = $`#readerarea`, swipeLabels = $$`.swipe`;
readerArea.style.transform = `translate(${navPos}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: `<i class="fas fa-angle-left"></i> Previous Chapter`
}),
crel("div", {
className: "swipe swipe-right",
innerHTML: ` Next Chapter <i class="fas fa-angle-right"></i>`
})
));
// 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 (?<chapter>\d+)\b|\bch.(?<ch>\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 class="epxs">(?<epxs>.*?)<\/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<chapter_number) {
setLastReadChapter(post_id, chapter_id, chapter_number);
}
}
if (location.pathname.match(/^\/bookmarks?\/$/)) (async () => {
// 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.
function toggleLandscape() {
const enabled = document.body.style.transform !== "";
if (enabled) {
const offset = document.documentElement.scrollLeft;
document.body.style.transform="";
document.body.style.overflowY="";
scrollTo(0, offset);
} else {
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 = !enabled;
}