Sleazy Fork is available in English.
View hentaizap galleries in a vertical scroll instead of clicking page by page, with a zip download option
// ==UserScript==
// @name Hentaizap Vertical Scroll
// @namespace https://hentaizap.com
// @version 5.0
// @author JoyArz
// @description View hentaizap galleries in a vertical scroll instead of clicking page by page, with a zip download option
// @match https://hentaizap.com/gallery/*/
// @grant GM_xmlhttpRequest
// @connect hentaizap.com
// @connect *.hentaizap.com
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
console.log('[VS Debug] Script loaded! Host:', window.location.hostname);
const WIDTH_STORAGE_KEY = 'hentaizap_vs_width_percent';
const MOBILE_BREAKPOINT = 1024;
const STYLE = `
body { background: #0f0f14 !important; }
#content { background: #0f0f14 !important; }
.vs-container { position: relative; width: 100%; max-width: 100vw; margin: 0 auto; padding-top: 20px; }
.vs-page { position: relative; width: 100%; display: flex; justify-content: center; align-items: flex-start; background: #0f0f14; margin-bottom: 8px; min-height: 100px; }
.vs-page img { max-width: 100%; max-height: 100vh; width: auto; height: auto; object-fit: contain; display: block; background: #1a1a22; }
.vs-page img.loading { opacity: 0.3; background: transparent; }
.vs-page img.uniform-width { height: auto; }
.vs-loading { position: fixed; bottom: 24px; left: 24px; background: rgba(20, 20, 26, 0.6); border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); color: #e8e8ec; padding: 8px 16px; border-radius: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px; z-index: 9999; text-align: center; }
.vs-nav { position: fixed; bottom: 24px; right: 24px; display: flex; flex-direction: column; gap: 8px; z-index: 9998; padding: 8px; background: rgba(20, 20, 26, 0.55); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 22px; backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); }
.vs-nav button { width: 38px; height: 38px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.06); color: #e8e8ec; font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease; }
.vs-nav button:hover { background: rgba(255, 51, 102, 0.18); color: #ff3366; transform: scale(1.06); }
.vs-nav button:active { transform: scale(0.94); }
.vs-nav-top { position: fixed; top: 24px; right: 24px; z-index: 9998; display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: rgba(20, 20, 26, 0.55); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 18px; backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.vs-width-control { display: flex; align-items: center; gap: 6px; }
.vs-width-control label { font-size: 11px; letter-spacing: 0.02em; color: #9a9aa5; user-select: none; }
.vs-width-control input[type="number"] { width: 44px; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 8px; color: #e8e8ec; font-size: 12px; padding: 5px 4px; text-align: center; }
.vs-width-control input[type="number"]:focus { outline: none; border-color: rgba(255, 51, 102, 0.6); background: rgba(255, 255, 255, 0.09); }
.vs-width-control input[type="number"]::-webkit-outer-spin-button,
.vs-width-control input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.vs-width-control input[type="number"] { -moz-appearance: textfield; }
.vs-width-control .vs-width-unit { font-size: 11px; color: #6f6f78; }
.vs-width-control button { width: 24px; height: 24px; border-radius: 50%; border: none; background: rgba(255, 255, 255, 0.06); color: #9a9aa5; font-size: 12px; line-height: 1; cursor: pointer; transition: background 0.18s ease, color 0.18s ease; }
.vs-width-control button:hover { background: rgba(255, 51, 102, 0.18); color: #ff3366; }
.vs-nav-top #vs-show-failed { padding: 5px 10px; border-radius: 10px; border: none; background: rgba(255, 51, 102, 0.16); color: #ff7a93; font-size: 11px; font-weight: 500; cursor: pointer; transition: background 0.18s ease; }
.vs-nav-top #vs-show-failed:hover { background: rgba(255, 51, 102, 0.28); }
.vs-download-wrap { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin: 14px 0 20px; padding: 14px 16px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 16px; backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%); max-width: 280px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.vs-download-btn { display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.10); background: rgba(255, 255, 255, 0.05); color: #e8e8ec; font-size: 13px; font-family: inherit; cursor: pointer; transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease; }
.vs-download-btn:hover { background: rgba(255, 51, 102, 0.14); border-color: rgba(255, 51, 102, 0.4); }
.vs-download-btn:active { transform: scale(0.98); }
.vs-download-btn:disabled { opacity: 0.55; cursor: default; }
.vs-download-icon { font-size: 14px; }
.vs-download-progress { display: flex; align-items: center; gap: 10px; width: 100%; }
.vs-download-progress-track { flex: 1; height: 4px; border-radius: 4px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
.vs-download-progress-bar { height: 100%; width: 0%; background: #ff3366; transition: width 0.2s ease; }
.vs-download-progress-text { font-size: 11px; color: #9a9aa5; white-space: nowrap; }
.vs-error { color: #ff3366; padding: 20px; text-align: center; }
.vs-page-failed { border: 2px dashed #ff3366; }
.vs-page-failed::after { content: 'Failed'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #ff3366; font-size: 24px; font-weight: bold; }
.hidden { display: none !important; }
`;
const NAV_HTML = `
<div class="vs-nav">
<button id="vs-scroll-top" title="Scroll to top">▲</button>
<button id="vs-scroll-bottom" title="Scroll to bottom">▼</button>
<button id="vs-retry-failed" title="Retry failed pages">↻</button>
</div>
<div class="vs-nav-top">
<div class="vs-width-control">
<label for="vs-width-input">Width</label>
<input type="number" id="vs-width-input" min="10" max="400" step="1" inputmode="numeric" placeholder="auto" title="Custom width as a percent. Leave empty for the device default.">
<span class="vs-width-unit">%</span>
<button id="vs-width-reset" title="Reset to device default">↺</button>
</div>
<button id="vs-show-failed"></button>
</div>
`;
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[n] = c;
}
return table;
})();
function crc32(bytes) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) {
crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function asciiBytes(str) {
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xFF;
return bytes;
}
function toDosDateTime(date) {
const dosTime = ((date.getHours() & 0x1F) << 11) | ((date.getMinutes() & 0x3F) << 5) | (Math.floor(date.getSeconds() / 2) & 0x1F);
const dosDate = (((date.getFullYear() - 1980) & 0x7F) << 9) | (((date.getMonth() + 1) & 0xF) << 5) | (date.getDate() & 0x1F);
return { dosTime, dosDate };
}
// Builds an uncompressed (STORE method) ZIP file entirely with typed
// arrays. No compression, no third-party library, nothing async, so
// there is no Promise to hang on. files: [{ name, data: ArrayBuffer }]
function buildStoredZip(files) {
const { dosTime, dosDate } = toDosDateTime(new Date());
const localParts = [];
const centralParts = [];
let offset = 0;
for (const file of files) {
const nameBytes = asciiBytes(file.name);
const dataBytes = new Uint8Array(file.data);
const crc = crc32(dataBytes);
const size = dataBytes.length;
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true);
local.setUint16(4, 20, true);
local.setUint16(6, 0, true);
local.setUint16(8, 0, true);
local.setUint16(10, dosTime, true);
local.setUint16(12, dosDate, true);
local.setUint32(14, crc, true);
local.setUint32(18, size, true);
local.setUint32(22, size, true);
local.setUint16(26, nameBytes.length, true);
local.setUint16(28, 0, true);
localParts.push(new Uint8Array(local.buffer), nameBytes, dataBytes);
const central = new DataView(new ArrayBuffer(46));
central.setUint32(0, 0x02014b50, true);
central.setUint16(4, 20, true);
central.setUint16(6, 20, true);
central.setUint16(8, 0, true);
central.setUint16(10, 0, true);
central.setUint16(12, dosTime, true);
central.setUint16(14, dosDate, true);
central.setUint32(16, crc, true);
central.setUint32(20, size, true);
central.setUint32(24, size, true);
central.setUint16(28, nameBytes.length, true);
central.setUint16(30, 0, true);
central.setUint16(32, 0, true);
central.setUint16(34, 0, true);
central.setUint16(36, 0, true);
central.setUint32(38, 0, true);
central.setUint32(42, offset, true);
centralParts.push(new Uint8Array(central.buffer), nameBytes);
offset += 30 + nameBytes.length + size;
}
const centralStart = offset;
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(4, 0, true);
eocd.setUint16(6, 0, true);
eocd.setUint16(8, files.length, true);
eocd.setUint16(10, files.length, true);
eocd.setUint32(12, centralSize, true);
eocd.setUint32(16, centralStart, true);
eocd.setUint16(20, 0, true);
return new Blob([...localParts, ...centralParts, new Uint8Array(eocd.buffer)], { type: 'application/zip' });
}
class HentaizapScroll {
constructor() {
this.galleryId = null;
this.pages = 0;
this.baseWidth = 450;
this.widthPercent = this.loadSavedWidth(); // null = device default, number = manual override
this.container = null;
this.loadedPages = new Set();
this.failedPages = new Set();
this.loadedCount = 0;
this.initialized = false;
this.batchSize = 10;
this.currentBatchStart = 0;
this.isLoadingBatches = false;
this.maxRetries = 2;
this.hentaizapServer = null;
this.hentaizapDir = null;
this.hentaizapHash = null;
}
extractGalleryInfo() {
console.log('[VS Debug] extractGalleryInfo called');
const pathMatch = window.location.pathname.match(/\/gallery\/(\d+)/);
if (pathMatch) this.galleryId = pathMatch[1];
const loadServer = document.getElementById('load_server');
const loadDir = document.getElementById('load_dir');
const loadId = document.getElementById('load_id');
const loadPages = document.getElementById('load_pages');
if (loadServer) this.hentaizapServer = loadServer.value;
if (loadDir) this.hentaizapDir = loadDir.value;
if (loadId) this.hentaizapHash = loadId.value;
if (loadPages) this.pages = parseInt(loadPages.value, 10) || 0;
if (this.pages === 0) {
const pageNav = document.querySelector('.page-nav, .pagination, [class*="page"]');
if (pageNav) {
const nums = pageNav.textContent.match(/\d+/g);
if (nums && nums.length > 0) {
this.pages = parseInt(nums[nums.length - 1], 10);
}
}
}
if (this.pages === 0) {
const infoPg = document.querySelector('.info_pg');
if (infoPg) {
const m = infoPg.textContent.match(/Pages?:\s*(\d+)/i);
if (m) this.pages = parseInt(m[1], 10);
}
}
console.log('[VS Debug] Final result:', { galleryId: this.galleryId, pages: this.pages });
}
getImageUrls(pageNum) {
if (!this.hentaizapServer || !this.hentaizapDir || !this.hentaizapHash) {
return [];
}
const server = `m${this.hentaizapServer}`;
const base = `https://${server}.hentaizap.com/${this.hentaizapDir}/${this.hentaizapHash}/${pageNum}`;
return [`${base}.webp`, `${base}.jpg`, `${base}.jpeg`, `${base}.png`];
}
getImageUrl(pageNum) {
return this.getImageUrls(pageNum)[0];
}
isMobileOrTablet() {
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
}
loadSavedWidth() {
try {
const saved = localStorage.getItem(WIDTH_STORAGE_KEY);
if (saved !== null && saved !== '') {
const parsed = parseFloat(saved);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
} catch (e) {
console.log('[VS Debug] Could not read saved width:', e);
}
return null;
}
saveWidth(percent) {
try {
localStorage.setItem(WIDTH_STORAGE_KEY, String(percent));
} catch (e) {
console.log('[VS Debug] Could not save width:', e);
}
}
clearSavedWidth() {
try {
localStorage.removeItem(WIDTH_STORAGE_KEY);
} catch (e) {
console.log('[VS Debug] Could not clear saved width:', e);
}
}
setWidthPercent(percent) {
this.widthPercent = percent;
this.saveWidth(percent);
this.normalizeWidths();
}
resetWidthPercent() {
this.widthPercent = null;
this.clearSavedWidth();
this.normalizeWidths();
}
getMangaTitle() {
const candidates = ['h1.title', 'h1', '.gallery-title', '.entry-title', '.gallery_title', '.gallery-name', '.title'];
for (const sel of candidates) {
const el = document.querySelector(sel);
const text = el && el.textContent ? el.textContent.trim() : '';
if (text && !/^Pages?:/i.test(text)) {
return text;
}
}
let title = (document.title || '').trim();
title = title.replace(/\s*[-|]\s*Hentaizap.*$/i, '').trim();
if (title) return title;
return `gallery-${this.galleryId || 'unknown'}`;
}
sanitizeFilename(name) {
const cleaned = name
.replace(/[\\/:*?"<>|]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 150);
return cleaned || 'manga';
}
addDownloadButton() {
console.log('[VS Debug] addDownloadButton called');
const wrap = document.createElement('div');
wrap.className = 'vs-download-wrap';
wrap.id = 'vs-download-wrap';
wrap.innerHTML = `
<button id="vs-download-zip" class="vs-download-btn" title="Download all pages as a zip file">
<span class="vs-download-icon">⬇</span>
<span class="vs-download-label">Download ZIP</span>
</button>
<div class="vs-download-progress hidden" id="vs-download-progress">
<div class="vs-download-progress-track">
<div class="vs-download-progress-bar" id="vs-download-progress-bar"></div>
</div>
<span class="vs-download-progress-text" id="vs-download-progress-text">0 / 0</span>
</div>
`;
const infoPg = document.querySelector('.info_pg');
if (infoPg && infoPg.parentNode) {
infoPg.insertAdjacentElement('afterend', wrap);
console.log('[VS Debug] Download button placed after .info_pg');
} else if (this.container && this.container.parentNode) {
this.container.parentNode.insertBefore(wrap, this.container);
console.log('[VS Debug] .info_pg not found, placed download button above the scroll view');
} else {
document.body.appendChild(wrap);
}
const btn = document.getElementById('vs-download-zip');
btn.addEventListener('click', () => this.downloadZip(btn));
}
async downloadZip(btn) {
if (this.isDownloadingZip) return;
console.log('[VS Debug] Download ZIP clicked. GM_xmlhttpRequest:', typeof GM_xmlhttpRequest);
if (typeof GM_xmlhttpRequest !== 'function') {
console.log('[VS Debug] GM_xmlhttpRequest unavailable, cannot zip');
alert('Zip download needs a userscript manager permission update. Open this script in your userscript manager dashboard, look for a pending permission/host-access prompt, approve it, then reload this page.');
return;
}
this.isDownloadingZip = true;
btn.disabled = true;
const progressWrap = document.getElementById('vs-download-progress');
const progressBar = document.getElementById('vs-download-progress-bar');
const progressText = document.getElementById('vs-download-progress-text');
if (progressWrap) progressWrap.classList.remove('hidden');
const total = this.pages;
let done = 0;
let responseReceived = false;
const failed = [];
const zipFiles = [];
const updateProgress = () => {
if (progressBar) progressBar.style.width = `${Math.round((done / total) * 100)}%`;
if (progressText) progressText.textContent = `${done} / ${total}`;
};
updateProgress();
const stallWatchdog = setTimeout(() => {
if (!responseReceived) {
console.log('[VS Debug] No image response after 8s. This usually means your userscript manager has not granted host access for hentaizap.com / *.hentaizap.com yet. Check the manager dashboard for a pending permission prompt on this script, approve it, then click Download ZIP again.');
if (progressText) progressText.textContent = 'Waiting on host permission...';
}
}, 8000);
const fetchPage = (pageNum) => new Promise((resolve) => {
const urls = this.getImageUrls(pageNum);
const tryUrl = (urlIndex) => {
if (urlIndex >= urls.length) {
console.log('[VS Debug] zip: page', pageNum, 'failed in every format');
failed.push(pageNum);
done++;
updateProgress();
resolve();
return;
}
console.log('[VS Debug] zip: requesting page', pageNum, urls[urlIndex]);
GM_xmlhttpRequest({
method: 'GET',
url: urls[urlIndex],
responseType: 'arraybuffer',
timeout: 20000,
onload: (res) => {
responseReceived = true;
console.log('[VS Debug] zip: page', pageNum, 'status', res.status, 'bytes', res.response ? res.response.byteLength : 0);
if (res.status >= 200 && res.status < 300 && res.response) {
const ext = urls[urlIndex].split('.').pop();
const name = `${String(pageNum).padStart(3, '0')}.${ext}`;
zipFiles.push({ name, data: res.response });
done++;
updateProgress();
resolve();
} else {
tryUrl(urlIndex + 1);
}
},
onerror: (err) => {
responseReceived = true;
console.log('[VS Debug] zip: page', pageNum, 'request error', err);
tryUrl(urlIndex + 1);
},
ontimeout: () => {
console.log('[VS Debug] zip: page', pageNum, 'timed out');
tryUrl(urlIndex + 1);
}
});
};
tryUrl(0);
});
const concurrency = 5;
let cursor = 1;
const worker = async () => {
while (cursor <= total) {
const pageNum = cursor++;
await fetchPage(pageNum);
}
};
try {
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker));
} catch (e) {
console.log('[VS Debug] zip: worker loop threw', e);
}
clearTimeout(stallWatchdog);
zipFiles.sort((a, b) => a.name.localeCompare(b.name));
console.log('[VS Debug] zip: all page requests settled, done:', done, 'failed:', failed.length, 'files ready:', zipFiles.length);
if (progressText) progressText.textContent = 'Building zip...';
try {
console.log('[VS Debug] zip: building archive synchronously, no library, no async build step');
const blob = buildStoredZip(zipFiles);
console.log('[VS Debug] zip: blob built, size', blob.size);
const filename = `${this.sanitizeFilename(this.getMangaTitle())}.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
console.log('[VS Debug] Zip download triggered:', filename);
} catch (e) {
console.log('[VS Debug] Zip generation failed:', e);
if (progressText) progressText.textContent = 'Zip build failed, check console';
}
if (progressText) {
progressText.textContent = failed.length > 0
? `Done, ${failed.length} page(s) skipped`
: 'Done';
}
if (failed.length > 0) {
console.log('[VS Debug] Pages skipped from zip:', failed);
}
btn.disabled = false;
this.isDownloadingZip = false;
setTimeout(() => {
if (progressWrap) progressWrap.classList.add('hidden');
}, 3000);
}
init() {
console.log('[VS Debug] init called');
if (this.initialized) return;
this.initialized = true;
this.extractGalleryInfo();
if (!this.galleryId || !this.pages) {
console.log('[VS Debug] Gallery info missing');
this.showError('Could not detect gallery info');
return;
}
this.injectStyles();
this.createVerticalView();
this.addDownloadButton();
this.addNavigation();
this.setupKeyboardNav();
this.loadVisiblePages();
}
showError(msg) {
const errorDiv = document.createElement('div');
errorDiv.className = 'vs-error';
errorDiv.textContent = msg;
const content = document.querySelector('#content');
if (content) {
content.innerHTML = '';
content.appendChild(errorDiv);
}
}
injectStyles() {
console.log('[VS Debug] injectStyles called');
const styleEl = document.createElement('style');
styleEl.id = 'vs-styles';
styleEl.textContent = STYLE;
document.head.appendChild(styleEl);
}
createVerticalView() {
console.log('[VS Debug] createVerticalView called, pages:', this.pages);
const thumbnailContainer = document.querySelector('#thumbnail-container');
const hentaizapThumbs = document.querySelectorAll('.thumbnail, .thumb, [class*="thumb"]');
// Anchor is the element marking where the thumbnail grid sits.
// The scroll view gets inserted at this exact spot, so it
// replaces the grid in place instead of landing at the bottom
// of the page below unrelated content.
const anchor = thumbnailContainer || (hentaizapThumbs.length ? hentaizapThumbs[0] : null);
this.container = document.createElement('div');
this.container.className = 'vs-container';
for (let i = 1; i <= this.pages; i++) {
const pageEl = document.createElement('div');
pageEl.className = 'vs-page';
pageEl.dataset.page = i;
pageEl.innerHTML = `<img class="loading" data-src="${this.getImageUrl(i)}" alt="Page ${i}">`;
this.container.appendChild(pageEl);
}
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(this.container, anchor);
console.log('[VS Debug] Container inserted at thumbnail position');
} else {
const mainContent = document.querySelector('#content, main, .content, [role="main"]');
if (mainContent) {
mainContent.appendChild(this.container);
} else {
document.body.appendChild(this.container);
}
console.log('[VS Debug] No thumbnail anchor found, fell back to appending');
}
// Hide the original thumbnails rather than removing them, so
// navigation between galleries can detect and reset them again.
if (thumbnailContainer) thumbnailContainer.classList.add('hidden');
hentaizapThumbs.forEach(el => el.classList.add('hidden'));
const loading = document.createElement('div');
loading.className = 'vs-loading';
loading.id = 'vs-loading';
loading.textContent = `Loading...`;
document.body.appendChild(loading);
// Page count is fixed to this.pages. The loader below stops
// once every page is loaded or failed, no infinite loading.
setTimeout(() => {
this.loadInitialBatch();
}, 500);
}
addNavigation() {
console.log('[VS Debug] addNavigation called');
const navDiv = document.createElement('div');
navDiv.innerHTML = NAV_HTML;
document.body.appendChild(navDiv);
document.getElementById('vs-scroll-top').addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
document.getElementById('vs-scroll-bottom').addEventListener('click', () => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
document.getElementById('vs-retry-failed').addEventListener('click', () => {
this.retryFailedPages();
});
const widthInput = document.getElementById('vs-width-input');
widthInput.value = this.widthPercent !== null ? this.widthPercent : '';
const applyWidthFromInput = () => {
const raw = widthInput.value.trim();
if (raw === '') {
this.resetWidthPercent();
return;
}
const value = parseFloat(raw);
if (!isNaN(value) && value > 0) {
this.setWidthPercent(value);
} else {
widthInput.value = this.widthPercent !== null ? this.widthPercent : '';
}
};
widthInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
applyWidthFromInput();
widthInput.blur();
}
});
widthInput.addEventListener('blur', applyWidthFromInput);
document.getElementById('vs-width-reset').addEventListener('click', () => {
widthInput.value = '';
this.resetWidthPercent();
});
window.addEventListener('resize', () => {
if (this.widthPercent === null) {
this.normalizeWidths();
}
});
this.updateFailedButton();
}
updateFailedButton() {
const btn = document.getElementById('vs-show-failed');
if (!btn) return;
const count = this.failedPages.size;
btn.textContent = count > 0 ? `Failed: ${count}` : '';
btn.style.display = count > 0 ? 'block' : 'none';
}
retryFailedPages() {
console.log('[VS Debug] retryFailedPages called, failed count:', this.failedPages.size);
if (this.failedPages.size === 0) return;
const pages = Array.from(this.container.querySelectorAll('.vs-page-failed'));
this.failedPages.clear();
pages.forEach(pageEl => {
pageEl.classList.remove('vs-page-failed');
const img = pageEl.querySelector('img');
img.style.opacity = '';
img.alt = `Page ${pageEl.dataset.page}`;
this.loadImageWithRetry(pageEl, null, () => {});
});
this.updateFailedButton();
}
setupKeyboardNav() {
let scrollScheduled = false;
window.addEventListener('scroll', () => {
if (scrollScheduled) return;
scrollScheduled = true;
requestAnimationFrame(() => {
this.loadVisiblePages();
scrollScheduled = false;
});
}, { passive: true });
this.visibilityInterval = setInterval(() => {
this.loadVisiblePages();
}, 500);
}
loadInitialBatch() {
console.log('[VS Debug] loadInitialBatch called');
const loading = document.getElementById('vs-loading');
const pages = Array.from(this.container.querySelectorAll('.vs-page'));
const toLoad = pages.slice(0, Math.min(this.batchSize, this.pages));
console.log('[VS Debug] Loading initial batch of', toLoad.length, 'pages');
this.loadBatch(toLoad, loading, () => {
this.currentBatchStart = toLoad.length;
console.log('[VS Debug] Initial batch complete, batchStart:', this.currentBatchStart);
this.loadRemainingBatches();
});
}
loadBatch(pageEls, loading, onComplete) {
let loaded = 0;
const total = pageEls.length;
console.log('[VS Debug] loadBatch called with', total, 'elements');
if (total === 0) { if (onComplete) onComplete(); return; }
pageEls.forEach((pageEl) => {
this.loadImageWithRetry(pageEl, loading, () => {
loaded++;
if (loaded >= total && onComplete) onComplete();
});
});
}
loadImageWithRetry(pageEl, loading, onDone) {
const img = pageEl.querySelector('img');
const pageNum = parseInt(pageEl.dataset.page, 10);
const urls = this.getImageUrls(pageNum);
let retryCount = 0;
console.log('[VS Debug] loadImageWithRetry page', pageNum, 'urls:', urls);
const tryLoad = (urlIndex) => {
if (urlIndex >= urls.length) {
console.log('[VS Debug] All formats failed for page', pageNum);
this.markFailed(pageEl, pageNum, loading);
onDone();
return;
}
console.log('[VS Debug] Trying URL index', urlIndex, ':', urls[urlIndex]);
img.onload = () => {
img.classList.remove('loading');
this.loadedPages.add(pageNum);
this.loadedCount++;
if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
console.log('[VS Debug] Page', pageNum, 'loaded successfully');
this.normalizeWidths();
this.checkAllLoaded();
onDone();
};
img.onerror = () => {
console.log('[VS Debug] Page', pageNum, 'failed, urlIndex:', urlIndex, 'retryCount:', retryCount);
if (retryCount < this.maxRetries) {
retryCount++;
console.log('[VS Debug] Retrying page', pageNum, 'attempt', retryCount);
setTimeout(() => tryLoad(urlIndex), 300 * retryCount);
} else {
retryCount = 0;
console.log('[VS Debug] Trying next format for page', pageNum);
tryLoad(urlIndex + 1);
}
};
img.src = urls[urlIndex];
};
tryLoad(0);
}
markFailed(pageEl, pageNum, loading) {
this.failedPages.add(pageNum);
this.loadedPages.add(pageNum);
this.loadedCount++;
if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
pageEl.classList.add('vs-page-failed');
const img = pageEl.querySelector('img');
img.classList.remove('loading');
img.alt = `Failed page ${pageNum}`;
img.style.opacity = '0.3';
img.dataset.src = '';
this.checkAllLoaded();
this.updateFailedButton();
}
checkAllLoaded() {
const totalProcessed = this.loadedPages.size + this.failedPages.size;
if (totalProcessed >= this.pages) {
const loading = document.getElementById('vs-loading');
if (loading) loading.classList.add('hidden');
this.normalizeWidths();
if (this.visibilityInterval) {
clearInterval(this.visibilityInterval);
this.visibilityInterval = null;
console.log('[VS Debug] All pages loaded, stopped the periodic visibility check');
}
}
}
normalizeWidths() {
const images = this.container.querySelectorAll('.vs-page img');
// No manual override: tablets and phones get the full device
// width, desktop keeps the original 450px default (100%).
const effectivePercent = this.widthPercent !== null
? this.widthPercent
: (this.isMobileOrTablet() ? null : 100);
images.forEach(img => {
if (effectivePercent === null) {
img.classList.remove('uniform-width');
img.style.width = '';
img.style.maxWidth = '';
} else {
const targetWidth = Math.round(this.baseWidth * effectivePercent / 100);
img.classList.add('uniform-width');
img.style.width = targetWidth + 'px';
img.style.maxWidth = targetWidth + 'px';
}
});
}
loadRemainingBatches() {
console.log('[VS Debug] loadRemainingBatches called, batchStart:', this.currentBatchStart, 'pages:', this.pages);
if (this.isLoadingBatches) return;
if (this.currentBatchStart >= this.pages) {
console.log('[VS Debug] All batches loaded, stopping');
this.checkAllLoaded();
return;
}
this.isLoadingBatches = true;
const pages = Array.from(this.container.querySelectorAll('.vs-page'));
const nextBatch = pages.slice(this.currentBatchStart, this.currentBatchStart + this.batchSize);
console.log('[VS Debug] Loading next batch of', nextBatch.length, 'pages');
if (nextBatch.length === 0) {
this.isLoadingBatches = false;
this.checkAllLoaded();
return;
}
this.loadBatch(nextBatch, null, () => {
this.currentBatchStart += nextBatch.length;
console.log('[VS Debug] Batch complete, new batchStart:', this.currentBatchStart);
this.isLoadingBatches = false;
this.checkAllLoaded();
if (this.currentBatchStart < this.pages) {
setTimeout(() => this.loadRemainingBatches(), 100);
}
});
}
loadVisiblePages() {
if (!this.container) return;
if (this.loadedPages.size >= this.pages) return;
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
const pages = Array.from(this.container.querySelectorAll('.vs-page'));
const currentPage = Math.floor((scrollY + viewportHeight) / viewportHeight);
pages.forEach(pageEl => {
const pageNum = parseInt(pageEl.dataset.page, 10);
if (this.loadedPages.has(pageNum)) return;
if (pageNum <= currentPage + 3) {
const img = pageEl.querySelector('img');
if (img && img.dataset.src) {
console.log('[VS Debug] Loading visible page', pageNum);
this.loadedPages.add(pageNum);
this.loadImageWithRetry(pageEl, null, () => {});
}
}
});
}
}
function initScript() {
console.log('[VS Debug] initScript called, pathname:', window.location.pathname);
if (!window.location.pathname.match(/^\/gallery\/\d+/)) {
console.log('[VS Debug] URL does not match gallery pattern, skipping');
return;
}
const existingStyles = document.querySelector('#vs-styles');
if (existingStyles) existingStyles.remove();
const existingNav = document.querySelector('.vs-nav');
if (existingNav) existingNav.remove();
const existingNavTop = document.querySelector('.vs-nav-top');
if (existingNavTop) existingNavTop.remove();
const existingLoading = document.querySelector('#vs-loading');
if (existingLoading) existingLoading.remove();
const existingDownloadWrap = document.querySelector('#vs-download-wrap');
if (existingDownloadWrap) existingDownloadWrap.remove();
const vs = new HentaizapScroll();
let initAttempts = 0;
const maxInitAttempts = 20;
const tryInit = () => {
const content = document.querySelector('#content, main, .content, [role="main"]');
const hasThumbs = document.querySelector('.thumbnail, .thumb, [class*="thumb"], #thumbnail-container, #ap_thumbs, .gp_th');
console.log('[VS Debug] tryInit - content:', !!content, 'thumbs:', !!hasThumbs, 'initialized:', vs.initialized);
if ((content || hasThumbs) && !vs.initialized) {
console.log('[VS Debug] Calling vs.init()');
vs.init();
}
};
const observer = new MutationObserver((mutations) => {
if (vs.initialized) return;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeName === 'SECTION' || node.nodeName === 'DIV') {
if (node.matches?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th') ||
node.querySelector?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th')) {
console.log('[VS Debug] MutationObserver detected thumbs');
setTimeout(() => vs.init(), 100);
return;
}
}
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
console.log('[VS Debug] MutationObserver started');
const pollInit = setInterval(() => {
tryInit();
initAttempts++;
console.log('[VS Debug] Poll attempt', initAttempts, '/', maxInitAttempts);
if (vs.initialized || initAttempts >= maxInitAttempts) {
clearInterval(pollInit);
console.log('[VS Debug] Polling stopped, initialized:', vs.initialized);
}
}, 500);
tryInit();
}
let lastPathname = window.location.pathname;
setInterval(() => {
if (window.location.pathname !== lastPathname) {
console.log('[VS Debug] URL changed from', lastPathname, 'to', window.location.pathname);
lastPathname = window.location.pathname;
if (window.location.pathname.match(/^\/gallery\/\d+/)) {
setTimeout(() => {
const styles = document.querySelector('#vs-styles');
if (styles) styles.remove();
const nav = document.querySelector('.vs-nav');
if (nav) nav.remove();
const navTop = document.querySelector('.vs-nav-top');
if (navTop) navTop.remove();
const loading = document.querySelector('#vs-loading');
if (loading) loading.remove();
const downloadWrap = document.querySelector('#vs-download-wrap');
if (downloadWrap) downloadWrap.remove();
initScript();
}, 500);
}
}
}, 1000);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initScript);
} else {
initScript();
}
})();