// ==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.7
// @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.
// @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();
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;
}
/* 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;
}
/* 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' && ({
ArrowLeft: prev,
ArrowRight: next,
KeyA: prev,
KeyD: next
}[e.code]?.()), true);
// inertial drag scrolling
let [ delta, drag, dragged ] = [0, false, false];
events({
mousedown() {
[ delta, drag, dragged ] = [0, true, false];
},
mousemove(e) {
if (drag) {
scrollBy(0, delta=-e.movementY);
if (Math.abs(delta)>3) {
dragged = true;
document.body.classList.add('drag');
}
}
},
mouseup(e) {
if (drag) {
drag=false;
rAF((_, next) => Math.abs(delta*=0.95)>1 && next(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);
}
}
});
// don't be shy about loading an entire chapter
$$`img[loading="lazy"]`.forEach(img => img.loading="eager");
// retry loading broken images
const imgBackoff = new Map();
const imgNextRetry = new Map();
const retryImage = img => {
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);
}
}
observeDOM(() => {
[...document.images].filter(img=>img.complete && !img.naturalHeight).forEach(retryImage);
});
// and prefetch the next chapter's images for even less waiting.
const nextURL = $`.ch-next-btn`?.href;
if (nextURL) fetchHTML(nextURL).then(d => [...d.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.(?<chapter2>\d+)\b/i;
function decorateCards(reorder = true) {
$$$("//div[contains(@class, 'listupd')]//div[contains(@class, 'bsx')]/..").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, +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.
function decorateText() {
$$`.soralist a.series`.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 = +(chapterMatch.groups.chapter ?? chapterMatch.groups.chapter2);
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();
}
if ($`#chapterlist`) {
// 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))
));
}
}
// Tweak footer content on any page that has them
// 1. collapse related series.
const related = $$$("//span[text()='Related Series']/../../..")[0];
if (related) {
makeCollapsedFooter({label: 'Show Related Series', section: related});
related.style.display = 'none';
}
// 2. collapse comments.
const comments = $`#comments`;
if (comments) makeCollapsedFooter({label: 'Show Comments', section: comments});