Sleazy Fork is available in English.
Download images from pawchive.pw posts (ZIP / PDF / Strip). Also works on DoujinDesu, MangaToon, Kanzenin, KomikDewasa. JP title → romaji, multi-select, auto-scroll.
// ==UserScript==
// @name Pawchive Downloader
// @name:id Pawchive Downloader
// @name:en Pawchive Downloader
// @namespace https://sleazyfork.org/users/qxyraxp
// @version 1.0.0
// @description Download images from pawchive.pw posts (ZIP / PDF / Strip). Also works on DoujinDesu, MangaToon, Kanzenin, KomikDewasa. JP title → romaji, multi-select, auto-scroll.
// @description:id Download gambar dari post pawchive.pw (ZIP / PDF / Strip). Juga support DoujinDesu, MangaToon, Kanzenin, KomikDewasa. Judul JP → romaji, multi-select, auto-scroll.
// @description:en Download images from pawchive.pw posts (ZIP / PDF / Strip). Also supports DoujinDesu, MangaToon, Kanzenin, KomikDewasa. JP title → romaji, multi-select, auto-scroll.
// @author qxyraxp
// @license MIT
// @match *://pawchive.pw/*
// @match *://*.pawchive.pw/*
// @match https://doujin.desu.xxx/*
// @match http://doujin.desu.xxx/*
// @match *://*.mangatoon.cc/*
// @match *://mangatoon.cc/*
// @match *://kanzenin.info/*
// @match *://*.kanzenin.info/*
// @match *://kanzenin.xyz/*
// @match *://*.kanzenin.xyz/*
// @match *://komikdewasa.art/*
// @match *://*.komikdewasa.art/*
// @connect translate.googleapis.com
// @connect *
// @grant GM_download
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @run-at document-start
// @homepageURL https://sleazyfork.org
// @supportURL https://sleazyfork.org
// ==/UserScript==
(function () {
'use strict';
let imageURLs = [];
let panelOpen = false;
let autoScanInterval = null;
let isDark = localStorage.getItem('dd_theme') !== 'light';
let downloadHistory = JSON.parse(localStorage.getItem('dd_history') || '[]');
let isDownloading = false;
let thumbSize = localStorage.getItem('dd_thumb') || 'm';
let isCompact = localStorage.getItem('dd_compact') === '1';
let toolbarCollapsed = localStorage.getItem('dd_toolbar_collapsed') === '1';
let showSelectedOnly = false;
let accentColor = localStorage.getItem('dd_accent') || '#3b82f6';
let hoverPreviewTimer = null;
let downloadStartTime = 0;
let downloadBytesDone = 0;
let lastSelectedIndex = -1;
let lastDownloadMode = localStorage.getItem('dd_last_mode') || 'zip';
let isDraggingPanel = false;
let estimatedSizes = {}; // url -> bytes
let avgImageSize = 180000; // fallback ~180KB per image
let originalDocTitle = document.title;
let dlConcurrency = parseInt(localStorage.getItem('dd_concurrency') || '4', 10) || 4;
let jpegQuality = parseFloat(localStorage.getItem('dd_jpeg_q') || '0.92') || 0.92;
let autoOpenPanel = localStorage.getItem('dd_auto_open') !== '0';
let fabPosition = localStorage.getItem('dd_fab_pos') || 'top-right'; // top-right | bottom-right
let tabProgressEnabled = localStorage.getItem('dd_tab_progress') !== '0';
let autoMinimizeOnDl = localStorage.getItem('dd_auto_minimize') === '1';
let notifyEnabled = localStorage.getItem('dd_notify') !== '0';
// default = Downloads | ask = Save As (handle dipilih di AWAL klik) | folder = pilih folder
let saveMode = localStorage.getItem('dd_save_mode') || 'default';
let sessionDirHandle = null;
let sessionDirName = localStorage.getItem('dd_folder_label') || '';
let pendingFileHandle = null; // Save As: dipilih di awal, ditulis di akhir
let skipCoverCount = parseInt(localStorage.getItem('dd_skip_cover') || '2', 10);
if (skipCoverCount < 0) skipCoverCount = 0;
let uiLang = localStorage.getItem('dd_lang') || 'en'; // en | id — default English
let ddLogs = [];
const DD_LOG_MAX = 250;
const I18N = {
en: {
fileName: 'File Name',
saveFolder: 'Save Folder',
filePlaceholder: 'ZIP / PDF file name...',
refreshTitle: 'Fill from page title',
pickFolder: 'Pick folder (Chrome/Edge)',
resetFolder: 'Reset',
downloadsDefault: 'Downloads (default)',
folderSelected: 'Folder selected',
saveAsDialog: 'Save As (dialog)',
scan: 'Scan',
scroll: 'Scroll',
all: 'All',
none: 'None',
cover: 'Cover',
ends: 'Ends',
nextCh: 'Next Ch',
download: 'Download',
selected: 'Selected',
selectedDesc: 'individual files',
zipDesc: 'zip archive',
pdfDesc: 'per page',
stripDesc: 'long-strip PDF',
images: 'images',
selectedCount: 'selected',
of: 'of',
settings: 'Settings',
language: 'Language',
langId: 'ID',
langEn: 'EN',
save: 'Save',
resetDefault: 'Reset default',
log: 'Log',
help: 'Help',
statusHint: 'Shift+Click = range • R=Refresh A=All Z=ZIP Esc=Close',
emptyTitle: 'No images yet',
emptyDesc: 'Scroll the page until images appear,<br>then press Scan or use Auto Scroll',
emptyScroll: 'Auto Scroll',
panel: 'Panel',
close: 'Close (Esc)',
minimize: 'Minimize',
collapse: 'Collapse/Expand toolbar',
filter: 'Show selected only',
history: 'History',
chapters: 'Chapters',
theme: 'Theme',
sizeS: 'Small',
sizeM: 'Medium',
sizeL: 'Large',
compact: 'Compact',
folderSaved: 'Folder saved: ',
folderFail: 'Failed to pick folder.',
folderUnsupported: 'This browser does not support folder picker.\nUse Chrome or Edge.',
selectedOf: '{s} selected of {t} images',
concurrent: 'Concurrent download',
jpegQuality: 'JPEG quality (PDF)',
defaultMode: 'Default download mode',
skipCover: 'Skip cover (first panels)',
fabPos: 'DL button position',
saveLoc: 'Save location',
autoOpen: 'Auto-open panel on reader pages',
tabProg: 'Progress in browser tab title',
autoMin: 'Auto-minimize panel on download',
notify: 'Notify when download finishes',
topRight: 'Top right',
bottomRight: 'Bottom right',
saveDefault: 'Downloads folder (default)',
saveAsk: 'Ask location each file (Save As)',
saveFolderMode: 'Custom folder (Chrome/Edge)',
saveNote: 'Browser cannot force a fixed path. Folder mode: pick once per session.',
langToggle: 'Language ID/EN'
},
id: {
fileName: 'Nama File',
saveFolder: 'Folder Simpan',
filePlaceholder: 'Nama file ZIP / PDF...',
refreshTitle: 'Isi ulang dari judul halaman',
pickFolder: 'Pilih folder (Chrome/Edge)',
resetFolder: 'Reset',
downloadsDefault: 'Downloads (default)',
folderSelected: 'Folder dipilih',
saveAsDialog: 'Save As (dialog)',
scan: 'Scan',
scroll: 'Scroll',
all: 'All',
none: 'None',
cover: 'Cover',
ends: 'Ends',
nextCh: 'Next Ch',
download: 'Download',
selected: 'Terpilih',
selectedDesc: 'file satu-satu',
zipDesc: 'arsip zip',
pdfDesc: 'per halaman',
stripDesc: 'PDF nyambung',
images: 'gambar',
selectedCount: 'terpilih',
of: 'dari',
settings: 'Pengaturan',
language: 'Bahasa',
langId: 'ID',
langEn: 'EN',
save: 'Simpan',
resetDefault: 'Reset default',
log: 'Log',
help: 'Help',
statusHint: 'Shift+Click = range • R=Refresh A=All Z=ZIP Esc=Close',
emptyTitle: 'Belum ada gambar',
emptyDesc: 'Scroll halaman sampai gambar muncul,<br>lalu tekan Scan atau pakai Auto Scroll',
emptyScroll: 'Auto Scroll',
panel: 'Panel',
close: 'Tutup (Esc)',
minimize: 'Minimize',
collapse: 'Collapse/Expand toolbar',
filter: 'Tampilkan hanya terpilih',
history: 'History',
chapters: 'Chapter',
theme: 'Theme',
sizeS: 'Kecil',
sizeM: 'Sedang',
sizeL: 'Besar',
compact: 'Compact',
folderSaved: 'Folder tersimpan: ',
folderFail: 'Gagal memilih folder.',
folderUnsupported: 'Browser tidak mendukung pilih folder.\nGunakan Chrome atau Edge.',
selectedOf: '{s} terpilih dari {t} gambar',
concurrent: 'Concurrent download',
jpegQuality: 'Kualitas JPEG (PDF)',
defaultMode: 'Mode download default',
skipCover: 'Skip cover (jumlah panel awal)',
fabPos: 'Posisi tombol DL',
saveLoc: 'Lokasi simpan file',
autoOpen: 'Auto-buka panel di halaman reader',
tabProg: 'Progress di title tab browser',
autoMin: 'Minimize panel otomatis saat download',
notify: 'Notifikasi selesai download',
topRight: 'Pojok kanan atas',
bottomRight: 'Pojok kanan bawah',
saveDefault: 'Folder Downloads (default)',
saveAsk: 'Tanya lokasi tiap file (Save As)',
saveFolderMode: 'Pilih folder custom (Chrome/Edge)',
saveNote: 'Browser tidak bisa memaksa path tetap. Mode folder: pilih sekali per sesi.',
langToggle: 'Bahasa ID/EN'
}
};
function t(key) {
const pack = I18N[uiLang] || I18N.en;
return pack[key] || I18N.en[key] || key;
}
function toggleLang() {
uiLang = uiLang === 'en' ? 'id' : 'en';
localStorage.setItem('dd_lang', uiLang);
const btn = document.getElementById('dd-lang-btn');
if (btn) btn.textContent = uiLang === 'en' ? 'EN' : 'ID';
applyI18n();
}
function ddLog(level, msg, detail) {
const entry = {
t: new Date().toLocaleTimeString('id-ID', { hour12: false }),
level: level || 'info',
msg: String(msg || ''),
detail: detail != null ? String(detail).slice(0, 600) : ''
};
ddLogs.push(entry);
if (ddLogs.length > DD_LOG_MAX) ddLogs = ddLogs.slice(-DD_LOG_MAX);
try {
if (level === 'error') console.error('[DD]', msg, detail || '');
else if (level === 'warn') console.warn('[DD]', msg, detail || '');
} catch (e) {}
}
function showLogPanel() {
if (document.getElementById('dd-log-box')) return;
const box = document.createElement('div');
box.id = 'dd-log-box';
const rows = ddLogs.length
? ddLogs.slice().reverse().map(e => {
const cls = e.level === 'error' ? 'dd-log-err' : (e.level === 'warn' ? 'dd-log-warn' : 'dd-log-info');
return `<div class="dd-log-row ${cls}"><span class="dd-log-t">${e.t}</span><span class="dd-log-lv">${e.level}</span><span class="dd-log-msg">${escapeHtml(e.msg)}${e.detail ? ` <span class="dd-log-detail">${escapeHtml(e.detail)}</span>` : ''}</span></div>`;
}).join('')
: '<div class="dd-help-note">Belum ada log. Scan / download akan muncul di sini.</div>';
box.innerHTML = `
<div class="dd-modal-bg">
<div class="dd-modal dd-modal-wide">
<div class="dd-modal-head">
<b>⌁ Log / Debug</b>
<button id="dd-log-close" type="button">✕</button>
</div>
<div class="dd-modal-body">
<div class="dd-ch-toolbar">
<button type="button" id="dd-log-copy" class="dd-ch-btn">Copy</button>
<button type="button" id="dd-log-clear" class="dd-ch-btn">Clear</button>
<span class="dd-ch-count">${ddLogs.length} entry</span>
</div>
<div class="dd-log-list">${rows}</div>
<div class="dd-help-note">URL di-skip, error fetch, dan hasil scan tercatat di sini. Max ${DD_LOG_MAX} baris.</div>
</div>
</div>
</div>`;
document.body.appendChild(box);
document.getElementById('dd-log-close').onclick = () => box.remove();
box.querySelector('.dd-modal-bg').onclick = (e) => { if (e.target === e.currentTarget) box.remove(); };
document.getElementById('dd-log-clear').onclick = () => {
ddLogs = [];
box.remove();
showLogPanel();
};
document.getElementById('dd-log-copy').onclick = async () => {
const text = ddLogs.map(e => `[${e.t}] ${e.level.toUpperCase()} ${e.msg}${e.detail ? ' | ' + e.detail : ''}`).join('\n');
try {
await navigator.clipboard.writeText(text);
const st = document.getElementById('dd-status-text');
if (st) st.textContent = 'Log disalin';
} catch (e) {
prompt('Copy log:', text);
}
};
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function getMimeForName(filename) {
const f = (filename || '').toLowerCase();
if (f.endsWith('.zip') || f.endsWith('.cbz')) return 'application/zip';
if (f.endsWith('.pdf')) return 'application/pdf';
if (f.endsWith('.png')) return 'image/png';
if (f.endsWith('.webp')) return 'image/webp';
if (f.endsWith('.avif')) return 'image/avif';
return 'image/jpeg';
}
const DD_IDB_NAME = 'dd_downloader_db';
const DD_IDB_STORE = 'handles';
function idbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DD_IDB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(DD_IDB_STORE)) db.createObjectStore(DD_IDB_STORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function idbSet(key, value) {
try {
const db = await idbOpen();
return await new Promise((resolve, reject) => {
const tx = db.transaction(DD_IDB_STORE, 'readwrite');
tx.objectStore(DD_IDB_STORE).put(value, key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error);
});
} catch (e) { return false; }
}
async function idbGet(key) {
try {
const db = await idbOpen();
return await new Promise((resolve, reject) => {
const tx = db.transaction(DD_IDB_STORE, 'readonly');
const req = tx.objectStore(DD_IDB_STORE).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (e) { return null; }
}
async function idbDel(key) {
try {
const db = await idbOpen();
return await new Promise((resolve, reject) => {
const tx = db.transaction(DD_IDB_STORE, 'readwrite');
tx.objectStore(DD_IDB_STORE).delete(key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error);
});
} catch (e) { return false; }
}
async function persistDirHandle(handle, name) {
sessionDirHandle = handle;
sessionDirName = name || handle?.name || 'Folder';
saveMode = 'folder';
localStorage.setItem('dd_save_mode', 'folder');
localStorage.setItem('dd_folder_label', sessionDirName);
await idbSet('saveDir', handle);
updateFolderUI();
}
async function clearDirHandle() {
sessionDirHandle = null;
sessionDirName = '';
await idbDel('saveDir');
localStorage.removeItem('dd_folder_label');
}
async function restoreDirHandle() {
if (saveMode !== 'folder') return false;
try {
const handle = await idbGet('saveDir');
if (!handle) return false;
sessionDirHandle = handle;
sessionDirName = handle.name || sessionDirName || 'Folder';
localStorage.setItem('dd_folder_label', sessionDirName);
updateFolderUI();
return true;
} catch (e) {
return false;
}
}
async function verifyDirPermission(handle) {
if (!handle) return false;
try {
let p = await handle.queryPermission({ mode: 'readwrite' });
if (p === 'granted') return true;
p = await handle.requestPermission({ mode: 'readwrite' });
return p === 'granted';
} catch (e) {
return false;
}
}
function updateFolderUI() {
const el = document.getElementById('dd-folder-label');
if (!el) return;
if (saveMode === 'folder' && (sessionDirHandle || sessionDirName)) {
el.textContent = sessionDirName || sessionDirHandle?.name || t('folderSelected');
el.title = el.textContent;
el.classList.add('dd-folder-set');
} else if (saveMode === 'ask') {
el.textContent = t('saveAsDialog');
el.title = t('saveAsDialog');
el.classList.remove('dd-folder-set');
} else {
el.textContent = t('downloadsDefault');
el.title = t('downloadsDefault');
el.classList.remove('dd-folder-set');
}
}
function applyI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (key) el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (key) el.setAttribute('placeholder', t(key));
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (key) el.setAttribute('title', t(key));
});
// bottom stats labels need dynamic rebuild
updateSelectedUI();
updateFolderUI();
// menu descriptions
const menuMap = [
['xamz-download-selected', 'selected', 'selectedDesc'],
['xamz-download-zip', null, 'zipDesc'],
['xamz-download-pdf', null, 'pdfDesc'],
['xamz-download-strip', null, 'stripDesc']
];
menuMap.forEach(([id, labelKey, descKey]) => {
const btn = document.getElementById(id);
if (!btn) return;
const desc = btn.querySelector('.dd-menu-desc');
if (desc) desc.textContent = t(descKey);
if (labelKey) {
// keep structure: text + span
const nodes = [...btn.childNodes].filter(n => n.nodeType === 3);
if (nodes[0]) nodes[0].textContent = t(labelKey) + ' ';
}
});
const nextBtn = document.getElementById('dd-nextch-btn');
if (nextBtn) nextBtn.textContent = t('nextCh');
const dlMain = document.getElementById('dd-download-main');
if (dlMain) {
const badge = dlMain.querySelector('#badge-dl');
const badgeHtml = badge ? badge.outerHTML : '';
dlMain.innerHTML = `${t('download')} ${badgeHtml} ▾`;
}
const toolbarMap = {
'xamz-refresh': 'scan',
'dd-autoscroll': 'scroll',
'xamz-select-all': 'all',
'xamz-select-none': 'none',
'dd-skip-cover': 'cover',
'dd-skip-ends': 'ends'
};
Object.entries(toolbarMap).forEach(([id, key]) => {
const el = document.getElementById(id);
if (el) el.textContent = t(key);
});
const langBtn = document.getElementById('dd-lang-btn');
if (langBtn) {
langBtn.textContent = uiLang === 'en' ? 'EN' : 'ID';
langBtn.title = t('langToggle');
}
const sizeTitles = {
'dd-size-s': 'sizeS',
'dd-size-m': 'sizeM',
'dd-size-l': 'sizeL',
'dd-compact-btn': 'compact'
};
Object.entries(sizeTitles).forEach(([id, key]) => {
const el = document.getElementById(id);
if (el) el.title = t(key);
});
// re-render grid labels if open
const grid = document.getElementById('xamz-grid');
if (grid && grid.querySelector('.xamz-item')) {
scheduleUpdatePanel();
}
}
async function pickSaveFolder() {
if (typeof window.showDirectoryPicker !== 'function') {
alert(t('folderUnsupported'));
return false;
}
try {
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await persistDirHandle(handle, handle.name || 'Folder');
const st = document.getElementById('dd-status-text');
if (st) st.textContent = t('folderSaved') + sessionDirName;
return true;
} catch (e) {
if (e && e.name === 'AbortError') return false;
alert(t('folderFail'));
return false;
}
}
async function ensureSaveFolder() {
if (saveMode !== 'folder') return true;
if (!sessionDirHandle) await restoreDirHandle();
if (sessionDirHandle) {
if (await verifyDirPermission(sessionDirHandle)) return true;
sessionDirHandle = null;
}
return await pickSaveFolder();
}
// Harus dipanggil di awal klik (sebelum await lama) supaya dialog diizinkan browser
async function prepareSaveTarget(suggestedName) {
pendingFileHandle = null;
if (saveMode === 'folder') {
return await ensureSaveFolder();
}
if (saveMode === 'ask') {
if (typeof window.showSaveFilePicker !== 'function') {
alert('Save As tidak didukung browser ini. Gunakan Chrome/Edge, atau mode folder/Downloads.');
return false;
}
try {
const ext = (suggestedName.split('.').pop() || 'bin').toLowerCase();
pendingFileHandle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: [{
description: 'File',
accept: { [getMimeForName(suggestedName)]: ['.' + ext] }
}]
});
return true;
} catch (e) {
if (e && e.name === 'AbortError') return false;
alert('Gagal membuka dialog Save As.');
return false;
}
}
return true;
}
async function saveBlob(blob, filename) {
if (!blob) return false;
// Save As: tulis ke handle yang sudah dipilih di awal
if (pendingFileHandle) {
try {
const writable = await pendingFileHandle.createWritable();
await writable.write(blob);
await writable.close();
pendingFileHandle = null;
return true;
} catch (e) {
console.error(e);
pendingFileHandle = null;
return false;
}
}
// Mode folder
if (saveMode === 'folder') {
if (!sessionDirHandle) return false;
try {
const fileHandle = await sessionDirHandle.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
return true;
} catch (e) {
console.error(e);
return false;
}
}
// Default: Downloads
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
return true;
}
// ==================== Minimal ZIP ====================
function crc32(buf) {
let c = ~0;
for (let i = 0; i < buf.length; i++) {
c ^= buf[i];
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return ~c >>> 0;
}
function createZip(files) {
const localHeaders = [], fileDatas = [];
let offset = 0;
for (const f of files) {
const nameBytes = new TextEncoder().encode(f.name);
const data = f.data;
const crc = crc32(data);
const local = new Uint8Array(30 + nameBytes.length);
const v = new DataView(local.buffer);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(8, 0, true);
v.setUint32(14, crc, true);
v.setUint32(18, data.length, true);
v.setUint32(22, data.length, true);
v.setUint16(26, nameBytes.length, true);
local.set(nameBytes, 30);
localHeaders.push({ header: local, offset, crc, size: data.length });
fileDatas.push(data);
offset += local.length + data.length;
}
const centralParts = [];
let centralSize = 0;
for (let i = 0; i < files.length; i++) {
const h = localHeaders[i];
const nameBytes = new TextEncoder().encode(files[i].name);
const central = new Uint8Array(46 + nameBytes.length);
const v = new DataView(central.buffer);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint32(16, h.crc, true);
v.setUint32(20, h.size, true);
v.setUint32(24, h.size, true);
v.setUint16(28, nameBytes.length, true);
v.setUint32(42, h.offset, true);
central.set(nameBytes, 46);
centralParts.push(central);
centralSize += central.length;
}
const end = new Uint8Array(22);
const ev = new DataView(end.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, files.length, true);
ev.setUint16(10, files.length, true);
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true);
const total = offset + centralSize + 22;
const zip = new Uint8Array(total);
let pos = 0;
for (let i = 0; i < files.length; i++) {
zip.set(localHeaders[i].header, pos); pos += localHeaders[i].header.length;
zip.set(fileDatas[i], pos); pos += fileDatas[i].length;
}
for (const c of centralParts) { zip.set(c, pos); pos += c.length; }
zip.set(end, pos);
return new Blob([zip], { type: 'application/zip' });
}
// ==================== Ambil Judul ====================
function getTitle() {
try {
const h1 = document.querySelector('h1.entry-title') ||
document.querySelector('h1.text-xl') ||
document.querySelector('h1.font-black') ||
document.querySelector('h1.post__title') ||
document.querySelector('.post__title') ||
document.querySelector('h1');
if (h1) {
let text = cleanTitleSuffix(h1.textContent.trim()
.replace(/[\\/:*?"<>|]/g, '')
.replace(/\s+/g, ' ')
.trim());
if (text.length > 3) return text.slice(0, 120);
}
let pageTitle = document.title || '';
pageTitle = pageTitle
.replace(/\s*[-|–]\s*DoujinDesu.*$/i, '')
.replace(/\s*[-|–]\s*Manga\s*Toon.*$/i, '')
.replace(/\s*[-|–]\s*kanzenin.*$/i, '')
.replace(/\s*[-|–]\s*komikdewasa.*$/i, '')
.replace(/\s*[-|–]\s*pawchive.*$/i, '')
.replace(/\s*\|\s*Kemono.*$/i, '')
.trim();
if (pageTitle.length > 3) {
return cleanTitleSuffix(pageTitle.replace(/[\\/:*?"<>|]/g, '')).slice(0, 120);
}
} catch (e) {}
return '';
}
function cleanTitleSuffix(text) {
if (!text) return '';
return String(text)
.replace(/\s*\(\s*Pixiv\s*Fanbox\s*\)\s*/gi, ' ')
.replace(/\s*\(\s*Fanbox\s*\)\s*/gi, ' ')
.replace(/\s*\(\s*Patreon\s*\)\s*/gi, ' ')
.replace(/\s*\(\s*Fantia\s*\)\s*/gi, ' ')
.replace(/\s*[-|–]\s*Pixiv\s*Fanbox\s*$/i, '')
.replace(/\s+/g, ' ')
.trim();
}
// Kana → Romaji (Hepburn). Kanji di-handle via Google transliterate async.
const KANA_ROMAJI = (() => {
const m = {};
const pairs = [
['きゃ','kya'],['きゅ','kyu'],['きょ','kyo'],['しゃ','sha'],['しゅ','shu'],['しょ','sho'],
['ちゃ','cha'],['ちゅ','chu'],['ちょ','cho'],['にゃ','nya'],['にゅ','nyu'],['にょ','nyo'],
['ひゃ','hya'],['ひゅ','hyu'],['ひょ','hyo'],['みゃ','mya'],['みゅ','myu'],['みょ','myo'],
['りゃ','rya'],['りゅ','ryu'],['りょ','ryo'],['ぎゃ','gya'],['ぎゅ','gyu'],['ぎょ','gyo'],
['じゃ','ja'],['じゅ','ju'],['じょ','jo'],['びゃ','bya'],['びゅ','byu'],['びょ','byo'],
['ぴゃ','pya'],['ぴゅ','pyu'],['ぴょ','pyo'],
['キャ','kya'],['キュ','kyu'],['キョ','kyo'],['シャ','sha'],['シュ','shu'],['ショ','sho'],
['チャ','cha'],['チュ','chu'],['チョ','cho'],['ニャ','nya'],['ニュ','nyu'],['ニョ','nyo'],
['ヒャ','hya'],['ヒュ','hyu'],['ヒョ','hyo'],['ミャ','mya'],['ミュ','myu'],['ミョ','myo'],
['リャ','rya'],['リュ','ryu'],['リョ','ryo'],['ギャ','gya'],['ギュ','gyu'],['ギョ','gyo'],
['ジャ','ja'],['ジュ','ju'],['ジョ','jo'],['ビャ','bya'],['ビュ','byu'],['ビョ','byo'],
['ピャ','pya'],['ピュ','pyu'],['ピョ','pyo'],
['あ','a'],['い','i'],['う','u'],['え','e'],['お','o'],
['か','ka'],['き','ki'],['く','ku'],['け','ke'],['こ','ko'],
['さ','sa'],['し','shi'],['す','su'],['せ','se'],['そ','so'],
['た','ta'],['ち','chi'],['つ','tsu'],['て','te'],['と','to'],
['な','na'],['に','ni'],['ぬ','nu'],['ね','ne'],['の','no'],
['は','ha'],['ひ','hi'],['ふ','fu'],['へ','he'],['ほ','ho'],
['ま','ma'],['み','mi'],['む','mu'],['め','me'],['も','mo'],
['や','ya'],['ゆ','yu'],['よ','yo'],
['ら','ra'],['り','ri'],['る','ru'],['れ','re'],['ろ','ro'],
['わ','wa'],['を','wo'],['ん','n'],
['が','ga'],['ぎ','gi'],['ぐ','gu'],['げ','ge'],['ご','go'],
['ざ','za'],['じ','ji'],['ず','zu'],['ぜ','ze'],['ぞ','zo'],
['だ','da'],['ぢ','ji'],['づ','zu'],['で','de'],['ど','do'],
['ば','ba'],['び','bi'],['ぶ','bu'],['べ','be'],['ぼ','bo'],
['ぱ','pa'],['ぴ','pi'],['ぷ','pu'],['ぺ','pe'],['ぽ','po'],
['ぁ','a'],['ぃ','i'],['ぅ','u'],['ぇ','e'],['ぉ','o'],
['ゃ','ya'],['ゅ','yu'],['ょ','yo'],['っ',''] ,
['ア','a'],['イ','i'],['ウ','u'],['エ','e'],['オ','o'],
['カ','ka'],['キ','ki'],['ク','ku'],['ケ','ke'],['コ','ko'],
['サ','sa'],['シ','shi'],['ス','su'],['セ','se'],['ソ','so'],
['タ','ta'],['チ','chi'],['ツ','tsu'],['テ','te'],['ト','to'],
['ナ','na'],['ニ','ni'],['ヌ','nu'],['ネ','ne'],['ノ','no'],
['ハ','ha'],['ヒ','hi'],['フ','fu'],['ヘ','he'],['ホ','ho'],
['マ','ma'],['ミ','mi'],['ム','mu'],['メ','me'],['モ','mo'],
['ヤ','ya'],['ユ','yu'],['ヨ','yo'],
['ラ','ra'],['リ','ri'],['ル','ru'],['レ','re'],['ロ','ro'],
['ワ','wa'],['ヲ','wo'],['ン','n'],
['ガ','ga'],['ギ','gi'],['グ','gu'],['ゲ','ge'],['ゴ','go'],
['ザ','za'],['ジ','ji'],['ズ','zu'],['ゼ','ze'],['ゾ','zo'],
['ダ','da'],['ヂ','ji'],['ヅ','zu'],['デ','de'],['ド','do'],
['バ','ba'],['ビ','bi'],['ブ','bu'],['ベ','be'],['ボ','bo'],
['パ','pa'],['ピ','pi'],['プ','pu'],['ペ','pe'],['ポ','po'],
['ァ','a'],['ィ','i'],['ゥ','u'],['ェ','e'],['ォ','o'],
['ャ','ya'],['ュ','yu'],['ョ','yo'],['ッ',''],
['ー','-'],['〜','-'],['・',' '],[' ',' ']
];
for (const [k, v] of pairs) m[k] = v;
return m;
})();
function kanaToRomaji(text) {
if (!text) return '';
let out = '';
let i = 0;
const s = String(text);
while (i < s.length) {
// sokuon っ/ッ → double next consonant
if ((s[i] === 'っ' || s[i] === 'ッ') && i + 1 < s.length) {
const next2 = s.slice(i + 1, i + 3);
const next1 = s[i + 1];
const rom = KANA_ROMAJI[next2] || KANA_ROMAJI[next1] || '';
if (rom) {
out += rom[0] === 'c' ? 't' : rom[0];
i++;
continue;
}
}
const two = s.slice(i, i + 2);
if (KANA_ROMAJI[two] !== undefined) {
out += KANA_ROMAJI[two];
i += 2;
continue;
}
if (KANA_ROMAJI[s[i]] !== undefined) {
out += KANA_ROMAJI[s[i]];
i++;
continue;
}
out += s[i];
i++;
}
return out
.replace(/n([bmp])/gi, 'm$1')
.replace(/\s+/g, ' ')
.trim();
}
function hasJapanese(text) {
return /[\u3040-\u30ff\u3400-\u9fff]/.test(text || '');
}
function hasKanji(text) {
return /[\u3400-\u9fff]/.test(text || '');
}
// Sanitize for filename
function sanitizeFilename(name) {
return cleanTitleSuffix(
(name || '')
.replace(/[\\/:*?"<>|]/g, '')
.replace(/\s+/g, ' ')
.trim()
).slice(0, 120);
}
// Local-only romaji (kana). Kanji tetap jika ada.
function toRomajiLocal(text) {
if (!text) return '';
return sanitizeFilename(kanaToRomaji(text));
}
// Full romaji via Google transliterate (dt=rm). Fallback ke local.
function toRomajiAsync(text) {
return new Promise((resolve) => {
if (!text || !hasJapanese(text)) {
resolve(sanitizeFilename(text));
return;
}
// Tanpa kanji: cukup konversi lokal
if (!hasKanji(text)) {
resolve(toRomajiLocal(text));
return;
}
try {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=ja&tl=en&dt=t&dt=rm&q=' + encodeURIComponent(text),
timeout: 8000,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
// Structure varies; romanization often in data[0][i][3] or separate field
let romaji = '';
if (Array.isArray(data) && Array.isArray(data[0])) {
// Collect any romanization snippets
for (const part of data[0]) {
if (part && part[3]) romaji += part[3];
else if (part && part[0] && !hasJapanese(part[0])) {
// sometimes translation is used if no rm
}
}
}
// Alternative: data[0].map(x => x[3]).join
if (!romaji && Array.isArray(data?.[0])) {
romaji = data[0].map(x => x && x[3] ? x[3] : '').join('');
}
if (romaji && romaji.trim()) {
resolve(sanitizeFilename(romaji));
} else {
// fallback: translate to English words (better than raw kanji for filename)
let eng = '';
if (Array.isArray(data?.[0])) {
eng = data[0].map(x => x && x[0] ? x[0] : '').join('');
}
if (eng && !hasJapanese(eng)) resolve(sanitizeFilename(eng));
else resolve(toRomajiLocal(text));
}
} catch (e) {
resolve(toRomajiLocal(text));
}
},
onerror: () => resolve(toRomajiLocal(text)),
ontimeout: () => resolve(toRomajiLocal(text))
});
} catch (e) {
resolve(toRomajiLocal(text));
}
});
}
async function fillZipNameRomaji() {
const input = document.getElementById('xamz-zipname');
if (!input) return;
const raw = getTitle();
if (!raw) return;
input.value = hasJapanese(raw) ? await toRomajiAsync(raw) : sanitizeFilename(raw);
}
// ==================== Deteksi ====================
function isTargetImage(url) {
if (!url || typeof url !== 'string') return false;
const u = url.toLowerCase();
// Thumbnail WordPress: nama-file-218x300.jpg
if (/\-\d{2,4}x\d{2,4}\.(jpe?g|webp|png|avif)(\?|$)/i.test(u)) return false;
if (
u.includes('discordapp.com') ||
u.includes('discord.com') ||
u.includes('cdn.discordapp') ||
u.includes('google') ||
u.includes('gstatic') ||
u.includes('facebook') ||
u.includes('fbcdn') ||
u.includes('twitter') ||
u.includes('twimg') ||
u.includes('youtube') ||
u.includes('ytimg') ||
u.includes('gravatar') ||
u.includes('emoji') ||
u.includes('avatar') ||
u.includes('icon') ||
u.includes('logo') ||
u.includes('favicon') ||
u.includes('data:image') ||
u.includes('/reactions/') ||
u.includes('/comments/') ||
u.includes('/content/web/') ||
u.includes('/content/reaction') ||
u.includes('/content/comment') ||
u.includes('pic.desu.xxx/content/') ||
u.includes('/sticker') ||
u.includes('/badge') ||
u.includes('/rank') ||
u.includes('/emote') ||
u.includes('?resize=') ||
// thumbnail diblokir kecuali pawchive/kemono-style (akan di-upgrade ke original)
(u.includes('thumbnail') && !u.includes('pawchive') && !u.includes('kemono') && !u.includes('coomer') && !u.includes('nekohouse')) ||
u.includes('i1.wp.com') ||
u.includes('i2.wp.com') ||
u.includes('s.w.org') ||
u.includes('ts-post-image') ||
u.includes('attachment-medium') ||
u.includes('attachment-thumbnail')
) return false;
// Skip banner/ads GIF, logo situs, branding
if (
u.includes('.gif') ||
u.includes('area-iklan') ||
u.includes('bkp-') ||
u.includes('komikdewasa-1.png') ||
u.includes('komikdewasa-1.') ||
// logo pendek: /KOMIKDEWASA-1.png (bukan chapter title panjang)
/\/komikdewasa(-\d+)?\.(png|jpe?g|webp)(\?|$)/i.test(u) ||
/\/uploads\/\d{4}\/\d{2}\/komikdewasa(-\d+)?\.(png|jpe?g|webp)/i.test(u)
) return false;
const isImageExt = u.includes('.webp') || u.includes('.jpg') || u.includes('.jpeg') || u.includes('.png') || u.includes('.avif');
if (!isImageExt) return false;
// KomikDewasa chapter panels (nama panjang + nomor)
// contoh: KOMIKDEWASA.ART-My-GFs-Mommy-Was-My-Sugar-Mommy-1.jpg
const isKomikDewasaChapter =
/komikdewasa\.art-[a-z0-9._-]{10,}-\d{1,3}\.(jpe?g|webp|png)(\?|$)/i.test(u) ||
(/-[0-9]{1,3}\.(jpe?g|webp|png)(\?|$)/i.test(u) && u.length > 90 && u.includes('komikdewasa'));
// Blokir wp-content di host situs sendiri (logo/assets), kecuali panel chapter KomikDewasa
const isCdnHost = u.includes('cdnasu.xyz') || u.includes('manga.linksaya.com') ||
u.includes('img.cdn.house') || u.includes('desu.pics') || u.includes('amz-ch');
if (u.includes('wp-content') && !isCdnHost && !isKomikDewasaChapter) return false;
if (isKomikDewasaChapter) return true;
// MangaToon / Themesia / Kanzenin CDN
if (
u.includes('manga.linksaya.com') ||
u.includes('cdnasu.xyz') ||
u.includes('/uploads/manga-images/') ||
u.includes('img.cdn.house') ||
/\/chapter[-_]?\d+\/\d+\.(jpe?g|webp|png|avif)(\?|$)/i.test(u) ||
(/\/\d+[\w\-]*\.(jpe?g|webp|png)(\?|$)/i.test(u) && (u.includes('/manga') || u.includes('/uploads/')))
) return true;
// DoujinDesu CDN / signed S3 — query X-Amz-* harus tetap lolos
if (
u.includes('amz-ch.desu.pics') ||
u.includes('desu.pics') ||
u.includes('x-amz-') ||
u.includes('storage/uploads')
) return true;
// pawchive / kemono / coomer / nekohouse archives
if (
u.includes('pawchive') ||
u.includes('file.pawchive') ||
u.includes('img.pawchive') ||
u.includes('kemono.') ||
u.includes('coomer.') ||
u.includes('nekohouse') ||
/\/data\/[a-f0-9]{2}\/[a-f0-9]{2}\/[a-f0-9]{40,}\./i.test(u) ||
(/\/[a-f0-9]{40,}\.(png|jpe?g|webp|avif)(\?|$)/i.test(u) && !u.includes('pic.desu.xxx'))
) return true;
// Hash panjang (path lama)
if (/\/[a-z0-9_-]{20,}\.(webp|jpg|jpeg|png|avif)(\?|$)/i.test(u) &&
!u.includes('pic.desu.xxx') &&
!u.includes('/content/')) {
return true;
}
// Nama file aneh rilis baru: "4x (1).webp", "page 01.webp", dll di host gambar
// (bukan di domain situs / content / reaction)
if (
/\.(webp|jpg|jpeg|png|avif)(\?|$)/i.test(u) &&
!u.includes('pic.desu.xxx') &&
!u.includes('/content/') &&
!u.includes('doujin.desu.xxx') &&
(u.includes('amazonaws') || u.includes('cloudfront') || u.includes('desu.pics') ||
/\/[a-z0-9%._()\-\s]{8,}\.(webp|jpg|jpeg|png|avif)/i.test(u))
) {
return true;
}
return false;
}
// Key dedup: pathname saja (tanpa query). JANGAN buang query di URL yang disimpan —
// signed S3 (X-Amz-*) butuh query string, kalau di-strip gambar gagal load.
function urlDedupKey(url) {
if (!url || typeof url !== 'string') return '';
try {
const u = new URL(url);
const host = u.hostname.toLowerCase().replace(/^www\./, '');
return host + u.pathname.toLowerCase();
} catch (e) {
return url.split('?')[0].split('#')[0].toLowerCase()
.replace(/^https?:\/\//i, '')
.replace(/^www\./, '');
}
}
// Simpan URL utuh (termasuk query signed). Hanya rapikan protocol/host.
function normalizeUrl(url) {
if (!url || typeof url !== 'string') return '';
try {
const u = new URL(url);
// Pertahankan search (X-Amz-Algorithm, dll) — kritis untuk CDN signed
return u.origin + u.pathname + u.search;
} catch (e) {
return url;
}
}
function urlAlreadyHave(url, list) {
const key = urlDedupKey(url);
if (!key) return false;
if (list.some(u => urlDedupKey(u) === key)) return true;
// pawchive/kemono: dedupe by filename hash (thumbnail vs original beda host)
const base = getBasename(url);
if (base && /^[a-f0-9]{32,}\.(png|jpe?g|webp|avif)$/i.test(base)) {
return list.some(u => getBasename(u) === base);
}
return false;
}
// pawchive / kemono-style: thumbnail → original full file
// img.pawchive.pw/thumbnail/data/xx/yy/HASH.png → file.pawchive.pw/data/xx/yy/HASH.png
function resolveArchiveImageUrl(url) {
if (!url || typeof url !== 'string') return url;
try {
let u = url;
// relative → absolute
if (u.startsWith('//')) u = location.protocol + u;
else if (u.startsWith('/')) u = location.origin + u;
// pawchive thumbnail → original
if (/img\.pawchive\.pw\/thumbnail\/data\//i.test(u)) {
u = u.replace(/img\.pawchive\.pw\/thumbnail\/data\//i, 'file.pawchive.pw/data/');
} else if (/\/\/img\./i.test(u) && /\/thumbnail\/data\//i.test(u) && /pawchive|kemono|coomer|nekohouse/i.test(u)) {
u = u.replace(/\/\/img\./i, '//file.').replace(/\/thumbnail\/data\//i, '/data/');
} else if (/\/thumbnail\/data\//i.test(u) && /pawchive|kemono|coomer/i.test(u)) {
u = u.replace(/\/thumbnail\/data\//i, '/data/');
u = u.replace(/\/\/img\./i, '//file.');
}
return u;
} catch (e) {
return url;
}
}
function addUrl(url) {
if (!url) return false;
url = resolveArchiveImageUrl(url);
if (urlAlreadyHave(url, imageURLs)) return false;
if (isTargetImage(url)) {
// Simpan FULL url (dengan query) supaya signed link tetap valid
imageURLs.push(normalizeUrl(url) || url);
return true;
}
return false;
}
function getPageNumFromUrl(url) {
try {
const path = new URL(url).pathname;
const file = path.split('/').pop() || '';
// Kanzenin: 01-7.jpg, 0001-7.jpg, 21-4.jpg → angka di depan (bukan setelah dash terakhir)
const mK = file.match(/^(\d+)-\d+\.(jpe?g|webp|png|avif)$/i);
if (mK) return parseInt(mK[1], 10);
// MangaToon: 12.jpeg
const m = file.match(/^(\d+)\.(jpe?g|webp|png|avif)$/i);
if (m) return parseInt(m[1], 10);
// KomikDewasa: Title-Name-1.jpg, Title-Name-17.jpg → angka di akhir
const mEnd = file.match(/-(\d{1,3})\.(jpe?g|webp|png|avif)$/i);
if (mEnd) return parseInt(mEnd[1], 10);
// fallback
const m2 = file.match(/^(\d+)/);
if (m2) return parseInt(m2[1], 10);
} catch (e) {}
return null;
}
function isSortByFilenameSite() {
return /mangatoon\.cc|kanzenin\.(info|xyz)|komikdewasa\.art/i.test(location.hostname);
}
// alias lama
function isMangaToonSite() {
return isSortByFilenameSite();
}
function getBasename(url) {
try {
const path = new URL(url).pathname;
return path.split('/').pop().toLowerCase();
} catch (e) {
return (url || '').split('/').pop().split('?')[0].toLowerCase();
}
}
function dedupeByBasename(urls) {
const seen = new Set();
const out = [];
for (const u of urls) {
const base = getBasename(u);
if (!base || seen.has(base)) continue;
seen.add(base);
out.push(u);
}
return out;
}
function sortUrlsByFilename(urls) {
return urls.slice().sort((a, b) => {
const na = getPageNumFromUrl(a);
const nb = getPageNumFromUrl(b);
if (na !== null && nb !== null) return na - nb;
if (na !== null) return -1;
if (nb !== null) return 1;
return a.localeCompare(b);
});
}
function scanXAmz() {
let changed = false;
const newUrls = [];
const before = imageURLs.length;
function tryAdd(raw) {
if (!raw) return;
const resolved = resolveArchiveImageUrl(raw);
if (!isTargetImage(resolved) && !isTargetImage(raw)) return;
const full = normalizeUrl(resolved) || resolved;
if (urlAlreadyHave(full, newUrls) || urlAlreadyHave(full, imageURLs)) return;
newUrls.push(full);
}
// Prioritas: #readerarea / Themesia / Kanzenin reader content
const readerImgs = document.querySelectorAll(
'#readerarea img.ts-main-image, #readerarea img, .readercontent img.ts-main-image, .readercontent img, img.ts-main-image, #content.readercontent img'
);
const allImgs = readerImgs.length > 0 ? readerImgs : document.querySelectorAll('img');
allImgs.forEach(img => {
const candidates = [
img.currentSrc, img.src,
img.dataset.src, img.dataset.lazySrc, img.dataset.original,
img.getAttribute('data-src'), img.getAttribute('data-full'),
img.parentElement?.href
];
for (const src of candidates) {
if (src) tryAdd(src);
}
});
// pawchive / kemono: link download original di .post__files / figure
if (/pawchive|kemono|coomer|nekohouse/i.test(location.hostname)) {
document.querySelectorAll(
'.post__files a, .scrape__files a, article a, figure a, a.fileThumb, a.post__attachment-link'
).forEach(a => {
const href = a.href || a.getAttribute('href');
tryAdd(href);
});
}
try {
performance.getEntriesByType('resource').forEach(entry => {
const resolved = resolveArchiveImageUrl(entry.name);
if ((isTargetImage(resolved) || isTargetImage(entry.name)) &&
!urlAlreadyHave(resolved, newUrls) && !urlAlreadyHave(resolved, imageURLs)) {
const full = normalizeUrl(resolved) || resolved;
newUrls.push(full);
if (entry.transferSize > 0) {
estimatedSizes[full] = entry.transferSize;
estimatedSizes[entry.name] = entry.transferSize;
}
}
});
} catch (e) {
ddLog('warn', 'Performance scan gagal', e && e.message);
}
if (newUrls.length > 0) {
imageURLs.push(...newUrls);
changed = true;
ddLog('info', `Scan +${newUrls.length} gambar`, `total ${imageURLs.length}`);
}
// MangaToon / Kanzenin: dedupe nama file + urutkan
if (isSortByFilenameSite() && imageURLs.length > 1) {
const deduped = dedupeByBasename(imageURLs);
const sorted = sortUrlsByFilename(deduped);
if (sorted.length !== imageURLs.length || !sorted.every((u, i) => u === imageURLs[i])) {
const removed = imageURLs.length - sorted.length;
imageURLs = sorted;
changed = true;
if (removed > 0) ddLog('info', `Dedupe -${removed}`, `sisa ${sorted.length}`);
}
}
// pawchive / archive: selalu dedupe by basename (hash)
if (/pawchive|kemono|coomer|nekohouse/i.test(location.hostname) && imageURLs.length > 1) {
const beforeLen = imageURLs.length;
// prefer file.* (original) over img.* (thumbnail)
const byBase = new Map();
for (const u of imageURLs) {
const b = getBasename(u);
if (!b) continue;
const prev = byBase.get(b);
if (!prev) byBase.set(b, u);
else if (/file\./i.test(u) && !/file\./i.test(prev)) byBase.set(b, u);
else if (!/thumbnail/i.test(u) && /thumbnail/i.test(prev)) byBase.set(b, u);
}
const deduped = [...byBase.values()];
if (deduped.length !== beforeLen) {
imageURLs = deduped;
changed = true;
ddLog('info', `Archive dedupe -${beforeLen - deduped.length}`, `sisa ${deduped.length}`);
}
}
if (changed) scheduleUpdatePanel();
else if (before === 0 && imageURLs.length === 0) {
ddLog('warn', 'Scan: 0 gambar ditemukan', location.pathname);
}
return changed;
}
let _updatePanelTimer = null;
function scheduleUpdatePanel() {
if (_updatePanelTimer) return;
_updatePanelTimer = setTimeout(() => {
_updatePanelTimer = null;
updatePanel();
}, 400);
}
if ('PerformanceObserver' in window) {
try {
new PerformanceObserver((list) => {
let changed = false;
for (const entry of list.getEntries()) {
if (addUrl(entry.name)) changed = true;
if (entry.transferSize > 0) {
estimatedSizes[entry.name] = entry.transferSize;
estimatedSizes[normalizeUrl(entry.name)] = entry.transferSize;
}
}
if (changed && isSortByFilenameSite()) {
imageURLs = sortUrlsByFilename(dedupeByBasename(imageURLs));
}
// debounce — jangan rebuild grid tiap resource baru
if (changed) scheduleUpdatePanel();
}).observe({ type: 'resource', buffered: true });
} catch (e) {}
}
// ==================== Estimasi Ukuran ====================
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const val = bytes / Math.pow(1024, i);
return (i === 0 ? val : val.toFixed(1)) + ' ' + units[i];
}
function getEstimatedSize(indexes) {
if (!indexes || !indexes.length) return 0;
let total = 0;
let known = 0;
indexes.forEach(idx => {
const url = imageURLs[idx];
if (url && estimatedSizes[url]) {
total += estimatedSizes[url];
known++;
}
});
// kalau sebagian belum diketahui, pakai rata-rata
if (known > 0 && known < indexes.length) {
const avg = total / known;
total += avg * (indexes.length - known);
} else if (known === 0) {
total = avgImageSize * indexes.length;
}
return Math.round(total);
}
function updateSizeEstimate() {
const indexes = getSelectedIndexes();
const el = document.getElementById('dd-size-estimate');
if (!el) return;
if (!indexes.length) {
el.textContent = '';
return;
}
const bytes = getEstimatedSize(indexes);
el.textContent = `≈ ${formatBytes(bytes)}`;
el.title = `Estimasi ukuran total ${indexes.length} gambar terpilih`;
}
// ==================== History ====================
function addHistory(title, type, count) {
const entry = {
title: title || 'Untitled',
type,
count,
time: new Date().toLocaleString('id-ID')
};
downloadHistory.unshift(entry);
if (downloadHistory.length > 30) downloadHistory = downloadHistory.slice(0, 30);
localStorage.setItem('dd_history', JSON.stringify(downloadHistory));
}
function showHistory() {
const borderCol = isDark ? 'rgba(59,130,246,.2)' : 'rgba(59,130,246,.25)';
const list = downloadHistory.map((h) =>
`<div style="padding:8px 0;border-bottom:1px solid ${borderCol};font-size:13px;">
<b>${h.title}</b><br>
<span style="opacity:.7">${h.type} • ${h.count} gambar • ${h.time}</span>
</div>`
).join('') || '<div style="padding:20px;text-align:center;opacity:.6">Belum ada history</div>';
const box = document.createElement('div');
box.id = 'dd-history-box';
box.innerHTML = `
<div style="position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;">
<div style="background:${isDark ? '#0a0a0f' : '#fff'};color:${isDark ? '#fff' : '#111'};width:90%;max-width:420px;max-height:70vh;border-radius:12px;overflow:hidden;border:2px solid #3b82f6;">
<div style="padding:12px 16px;background:${isDark ? '#0c0c12' : '#dbeafe'};display:flex;align-items:center;justify-content:space-between;">
<b>📋 History Download</b>
<div>
<button id="dd-hist-clear" style="margin-right:8px;background:#3b82f6;color:#000;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;font-weight:bold;">Hapus Semua</button>
<button id="dd-hist-close" style="background:#333;color:#fff;border:none;border-radius:6px;padding:5px 10px;cursor:pointer;">✕</button>
</div>
</div>
<div style="padding:12px 16px;overflow:auto;max-height:50vh;">${list}</div>
</div>
</div>
`;
document.body.appendChild(box);
document.getElementById('dd-hist-close').onclick = () => box.remove();
document.getElementById('dd-hist-clear').onclick = () => {
downloadHistory = [];
localStorage.setItem('dd_history', '[]');
box.remove();
showHistory();
};
}
// ==================== Notification ====================
function notify(title, body) {
if (!notifyEnabled) return;
try {
if (typeof GM_notification === 'function') {
GM_notification({ title, text: body, timeout: 4000 });
} else if (Notification.permission === 'granted') {
new Notification(title, { body });
} else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(p => {
if (p === 'granted') new Notification(title, { body });
});
}
} catch (e) {}
}
// ==================== Mini Progress ====================
function ensureSpinnerStyle() {
if (document.getElementById('dd-spinner-style')) return;
const s = document.createElement('style');
s.id = 'dd-spinner-style';
s.textContent = `
@keyframes ddSpin{to{transform:rotate(360deg)}}
.dd-spinner{
display:inline-block;width:12px;height:12px;
border:2px solid rgba(59,130,246,.25);
border-top-color:#3b82f6;border-radius:50%;
animation:ddSpin .7s linear infinite;
vertical-align:-2px;margin-right:4px;flex-shrink:0;
}
#dd-mini-title{display:flex;align-items:center;gap:2px}
`;
document.head.appendChild(s);
}
function spinnerHTML() {
ensureSpinnerStyle();
return '<span class="dd-spinner" aria-hidden="true"></span>';
}
function showMiniProgress() {
let mini = document.getElementById('dd-mini-progress');
if (mini) return mini;
const bg = isDark ? '#0a0a0f' : '#eff6ff';
const textColor = isDark ? '#fff' : '#1a1a1a';
const muted = isDark ? '#93c5fd' : '#1d4ed8';
mini = document.createElement('div');
mini.id = 'dd-mini-progress';
mini.innerHTML = `
<div id="dd-mini-header">
<span id="dd-mini-title">${spinnerHTML()} Downloading...</span>
<button id="dd-mini-close">✕</button>
</div>
<div id="dd-mini-bar-wrap"><div id="dd-mini-bar"></div></div>
<div id="dd-mini-text">0%</div>
`;
document.body.appendChild(mini);
Object.assign(mini.style, {
position: 'fixed', right: '15px', bottom: '70px', zIndex: '2147483647',
width: '300px', background: bg, border: '2px solid #3b82f6',
borderRadius: '10px', padding: '10px 12px', color: textColor,
fontFamily: 'Arial, sans-serif', boxShadow: '0 4px 20px rgba(59,130,246,.4)', fontSize: '13px'
});
const header = mini.querySelector('#dd-mini-header');
Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px', fontWeight: 'bold' });
const closeBtn = mini.querySelector('#dd-mini-close');
Object.assign(closeBtn.style, { background: 'transparent', border: 'none', color: muted, cursor: 'pointer', fontSize: '14px' });
closeBtn.onclick = () => { if (!isDownloading) hideMiniProgress(); };
const barWrap = mini.querySelector('#dd-mini-bar-wrap');
Object.assign(barWrap.style, { height: '10px', background: 'rgba(59,130,246,.2)', borderRadius: '5px', overflow: 'hidden', marginBottom: '6px' });
const bar = mini.querySelector('#dd-mini-bar');
Object.assign(bar.style, { height: '100%', width: '0%', background: 'linear-gradient(90deg,#3b82f6,#60a5fa)', transition: 'width .25s', borderRadius: '5px' });
const text = mini.querySelector('#dd-mini-text');
Object.assign(text.style, { fontSize: '11px', color: muted, textAlign: 'center' });
return mini;
}
function setMiniProgress(percent, text, speedInfo) {
const pct = Math.min(100, Math.max(0, percent));
let displayText = text || (Math.round(pct) + '%');
// ETA + speed (MB/s)
if (downloadStartTime && pct > 1 && pct < 99 && isDownloading) {
const elapsed = (Date.now() - downloadStartTime) / 1000;
if (elapsed > 0.5) {
let speedStr = '';
const bytes = (speedInfo && speedInfo.bytes) ? speedInfo.bytes : downloadBytesDone;
if (bytes > 0) {
const mbPerSec = (bytes / (1024 * 1024)) / elapsed;
speedStr = ` • ${mbPerSec.toFixed(2)} MB/s`;
}
const totalEst = elapsed / (pct / 100);
const remain = Math.max(0, totalEst - elapsed);
if (remain > 0 && remain < 3600) {
const sec = Math.round(remain);
const etaStr = sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`;
displayText = `${text || Math.round(pct) + '%'}${speedStr} • sisa ~${etaStr}`;
} else {
displayText = `${text || Math.round(pct) + '%'}${speedStr}`;
}
}
}
// Update mini progress HANYA jika sudah ada (user sudah pencet Minimize)
const mini = document.getElementById('dd-mini-progress');
if (mini) {
const bar = mini.querySelector('#dd-mini-bar');
const label = mini.querySelector('#dd-mini-text');
const title = mini.querySelector('#dd-mini-title');
if (bar) bar.style.width = pct + '%';
if (label) label.textContent = displayText;
if (title && text) {
if (text.startsWith('✅') || text.startsWith('❌')) {
title.textContent = text;
} else {
title.innerHTML = spinnerHTML() + ' ' + displayText;
}
}
}
// Panel progress & status selalu di-update
const panelProg = document.getElementById('dd-panel-progress');
const panelBar = document.getElementById('dd-panel-progress-bar');
if (panelProg && panelBar) {
panelProg.classList.remove('hidden');
panelBar.style.width = pct + '%';
}
const statusText = document.getElementById('dd-status-text');
if (statusText && displayText) statusText.textContent = displayText;
// Progress di title tab browser
if (tabProgressEnabled && isDownloading) {
const short = (text || displayText || '').replace(/[•·].*$/, '').trim();
const match = short.match(/(\d+)\s*\/\s*(\d+)/);
if (match) {
document.title = `(${match[1]}/${match[2]}) ${Math.round(pct)}% · DL`;
} else if (pct >= 100) {
document.title = `✅ Selesai · DL`;
} else {
document.title = `${Math.round(pct)}% · DL`;
}
}
}
function restoreDocTitle() {
try {
if (originalDocTitle) document.title = originalDocTitle;
} catch (e) {}
}
function hideMiniProgress() {
document.getElementById('dd-mini-progress')?.remove();
const panelProg = document.getElementById('dd-panel-progress');
if (panelProg) {
panelProg.classList.add('hidden');
const panelBar = document.getElementById('dd-panel-progress-bar');
if (panelBar) panelBar.style.width = '0%';
}
}
function minimizePanel() {
const overlay = document.getElementById('xamz-overlay');
if (overlay) overlay.style.display = 'none';
const mini = showMiniProgress();
// Sync status saat minimize (terutama saat download sedang jalan)
if (isDownloading) {
const statusText = document.getElementById('dd-status-text');
const label = mini.querySelector('#dd-mini-text');
const title = mini.querySelector('#dd-mini-title');
const panelBar = document.getElementById('dd-panel-progress-bar');
const miniBar = mini.querySelector('#dd-mini-bar');
if (statusText && label) label.textContent = statusText.textContent || 'Downloading...';
if (title) title.innerHTML = spinnerHTML() + ' ' + (statusText?.textContent || 'Downloading...');
if (panelBar && miniBar) miniBar.style.width = panelBar.style.width || '0%';
}
const fab = document.getElementById('xamz-open-button');
if (fab) fab.style.display = 'none';
}
function restorePanel() {
const overlay = document.getElementById('xamz-overlay');
if (overlay) overlay.style.display = '';
const fab = document.getElementById('xamz-open-button');
if (fab) fab.style.display = 'none';
const box = document.getElementById('xamz-box');
if (box) {
box.classList.remove('dd-closing');
box.classList.add('dd-open');
}
}
// ==================== Next Chapter ====================
// ==================== Chapter List ====================
function collectChapters() {
const map = new Map(); // url -> {url, title, current}
const cur = location.href.split('#')[0];
function add(url, title) {
if (!url || typeof url !== 'string') return;
try {
const abs = new URL(url, location.href);
if (abs.origin !== location.origin) return;
const key = abs.href.split('#')[0];
if (map.has(key)) return;
const t = (title || abs.pathname.split('/').filter(Boolean).pop() || 'Chapter')
.replace(/\s+/g, ' ').trim().slice(0, 120);
map.set(key, { url: key, title: t, current: key === cur });
} catch (e) {}
}
// Themesia / MangaToon: select#chapter
const sel = document.querySelector('select#chapter, select.chapter, select[name="chapter"]');
if (sel) {
[...sel.options].forEach(opt => {
if (!opt.value) return;
add(opt.value.startsWith('http') ? opt.value : opt.value, opt.textContent);
});
}
// Themesia chapter list
document.querySelectorAll(
'#chapterlist a, .eplister a, .chapter-list a, .clstyle a, ul.clstyle a, .ch-list a, .listing-chapters_wrap a'
).forEach(a => {
if (a.href) add(a.href, a.textContent);
});
// Generic: links yang mirip chapter
document.querySelectorAll('a[href]').forEach(a => {
const href = a.href || '';
const text = (a.textContent || '').trim();
if (!/chapter|ch\.?\s*\d|episode|bab\s*\d/i.test(href + ' ' + text)) return;
if (/login|register|comment|discord|facebook|twitter|telegram|download\s*pdf/i.test(href + text)) return;
add(href, text);
});
// DoujinDesu / reader: link ke /reader/
if (/doujin\.desu/i.test(location.hostname)) {
document.querySelectorAll('a[href*="/reader/"], a[href*="/manga/"]').forEach(a => {
if (a.href) add(a.href, a.textContent);
});
}
let list = [...map.values()];
// Sort: coba angka chapter
list.sort((a, b) => {
const na = parseFloat((a.title.match(/(\d+(?:\.\d+)?)/) || [])[1]);
const nb = parseFloat((b.title.match(/(\d+(?:\.\d+)?)/) || [])[1]);
if (!isNaN(na) && !isNaN(nb)) return na - nb;
return a.title.localeCompare(b.title);
});
return list;
}
function getChapterQueue() {
try {
return JSON.parse(sessionStorage.getItem('dd_ch_queue') || '[]');
} catch (e) { return []; }
}
function getChapterQueueIndex() {
return parseInt(sessionStorage.getItem('dd_ch_queue_i') || '0', 10) || 0;
}
function setChapterQueue(urls, index) {
sessionStorage.setItem('dd_ch_queue', JSON.stringify(urls || []));
sessionStorage.setItem('dd_ch_queue_i', String(index || 0));
}
function clearChapterQueue() {
sessionStorage.removeItem('dd_ch_queue');
sessionStorage.removeItem('dd_ch_queue_i');
}
function showChapterPanel() {
if (document.getElementById('dd-chapter-box')) return;
const chapters = collectChapters();
const queue = getChapterQueue();
const qIdx = getChapterQueueIndex();
const box = document.createElement('div');
box.id = 'dd-chapter-box';
const rows = chapters.length
? chapters.map((ch, i) => `
<label class="dd-ch-row${ch.current ? ' dd-ch-current' : ''}">
<input type="checkbox" class="dd-ch-cb" data-url="${ch.url.replace(/"/g, '"')}" ${ch.current ? 'checked' : ''}>
<span class="dd-ch-num">${i + 1}</span>
<span class="dd-ch-title" title="${ch.title.replace(/"/g, '"')}">${ch.title}${ch.current ? ' · sekarang' : ''}</span>
</label>`).join('')
: `<div class="dd-help-note">Tidak ada daftar chapter di halaman ini.<br>
Buka halaman series / halaman yang punya list chapter, atau gunakan dropdown chapter situs.</div>`;
box.innerHTML = `
<div class="dd-modal-bg">
<div class="dd-modal dd-modal-wide">
<div class="dd-modal-head">
<b>📑 Daftar Chapter</b>
<button id="dd-ch-close" type="button">✕</button>
</div>
<div class="dd-modal-body">
<div class="dd-ch-toolbar">
<button type="button" id="dd-ch-all" class="dd-ch-btn">Semua</button>
<button type="button" id="dd-ch-none" class="dd-ch-btn">Batal</button>
<button type="button" id="dd-ch-from" class="dd-ch-btn" title="Centang dari chapter saat ini ke bawah">Dari sini ↓</button>
<span class="dd-ch-count">${chapters.length} chapter</span>
</div>
<div class="dd-ch-list">${rows}</div>
${queue.length ? `<div class="dd-help-note">Antrian aktif: ${Math.min(qIdx + 1, queue.length)}/${queue.length}
<button type="button" id="dd-ch-clear-q" class="dd-ch-btn" style="margin-left:8px">Hapus antrian</button></div>` : ''}
<div class="dd-set-actions" style="margin-top:12px">
<button type="button" id="dd-ch-open" class="dd-set-save">Buka terpilih</button>
<button type="button" id="dd-ch-queue" class="dd-set-reset">Antrian berurutan</button>
</div>
<div class="dd-help-note">Antrian berurutan: buka chapter satu per satu. Setelah selesai di satu chapter, pakai Next Ch / lanjut antrian.</div>
</div>
</div>
</div>`;
document.body.appendChild(box);
const listEl = box.querySelector('.dd-ch-list');
document.getElementById('dd-ch-close').onclick = () => box.remove();
box.querySelector('.dd-modal-bg').onclick = (e) => { if (e.target === e.currentTarget) box.remove(); };
document.getElementById('dd-ch-all').onclick = () => {
listEl.querySelectorAll('.dd-ch-cb').forEach(cb => { cb.checked = true; });
};
document.getElementById('dd-ch-none').onclick = () => {
listEl.querySelectorAll('.dd-ch-cb').forEach(cb => { cb.checked = false; });
};
document.getElementById('dd-ch-from').onclick = () => {
const cbs = [...listEl.querySelectorAll('.dd-ch-cb')];
let start = cbs.findIndex(cb => cb.closest('.dd-ch-current'));
if (start < 0) start = 0;
cbs.forEach((cb, i) => { cb.checked = i >= start; });
};
const clearQBtn = document.getElementById('dd-ch-clear-q');
if (clearQBtn) clearQBtn.onclick = () => { clearChapterQueue(); box.remove(); showChapterPanel(); };
function selectedUrls() {
return [...listEl.querySelectorAll('.dd-ch-cb:checked')].map(cb => cb.dataset.url).filter(Boolean);
}
document.getElementById('dd-ch-open').onclick = () => {
const urls = selectedUrls();
if (!urls.length) return alert('Pilih minimal 1 chapter');
clearChapterQueue();
box.remove();
closePanel();
location.href = urls[0];
};
document.getElementById('dd-ch-queue').onclick = () => {
const urls = selectedUrls();
if (!urls.length) return alert('Pilih minimal 1 chapter');
setChapterQueue(urls, 0);
box.remove();
closePanel();
location.href = urls[0];
};
}
function goNextInQueue() {
const queue = getChapterQueue();
if (!queue.length) return false;
let i = getChapterQueueIndex() + 1;
if (i >= queue.length) {
clearChapterQueue();
notify('Pawchive DL', 'Antrian chapter selesai');
return false;
}
setChapterQueue(queue, i);
location.href = queue[i];
return true;
}
function showQueueBanner() {
const queue = getChapterQueue();
if (!queue.length) return;
if (document.getElementById('dd-queue-banner')) return;
const i = getChapterQueueIndex();
const ban = document.createElement('div');
ban.id = 'dd-queue-banner';
ban.innerHTML = `
<span>📑 Antrian ${i + 1}/${queue.length}</span>
<button type="button" id="dd-queue-next">Chapter berikutnya</button>
<button type="button" id="dd-queue-stop">Stop</button>`;
Object.assign(ban.style, {
position: 'fixed', bottom: '16px', left: '16px', zIndex: '2147483646',
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', borderRadius: '10px',
background: 'rgba(12,12,20,.92)', color: '#fff',
border: '1px solid rgba(59,130,246,.5)',
fontFamily: 'Segoe UI, Arial, sans-serif', fontSize: '12px',
boxShadow: '0 4px 20px rgba(0,0,0,.4)'
});
document.body.appendChild(ban);
document.getElementById('dd-queue-next').onclick = () => goNextInQueue();
document.getElementById('dd-queue-stop').onclick = () => {
clearChapterQueue();
ban.remove();
};
// style buttons
ban.querySelectorAll('button').forEach(b => {
Object.assign(b.style, {
border: 'none', borderRadius: '6px', padding: '5px 10px',
cursor: 'pointer', fontWeight: '700', fontSize: '11px',
background: '#3b82f6', color: '#000'
});
});
document.getElementById('dd-queue-stop').style.background = '#333';
document.getElementById('dd-queue-stop').style.color = '#fff';
}
function goNextChapter() {
// Jika ada antrian chapter, prioritaskan
if (getChapterQueue().length && goNextInQueue()) return;
// Themesia / MangaToon: a.ch-next-btn
const chNext = document.querySelector('a.ch-next-btn:not(.disabled)') ||
document.querySelector('.chnav a[rel="next"]') ||
document.querySelector('a.ch-next-btn');
if (chNext && chNext.href && !chNext.classList.contains('disabled') && !chNext.href.includes('#/next')) {
closePanel();
location.href = chNext.href;
return;
}
// Chapter select dropdown (Themesia): pilih option berikutnya
const chapterSelect = document.querySelector('select#chapter');
if (chapterSelect && chapterSelect.selectedIndex >= 0) {
const nextOpt = chapterSelect.options[chapterSelect.selectedIndex + 1];
if (nextOpt && nextOpt.value) {
closePanel();
if (nextOpt.value.startsWith('http')) {
location.href = nextOpt.value;
} else {
chapterSelect.selectedIndex = chapterSelect.selectedIndex + 1;
chapterSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
return;
}
}
const candidates = [...document.querySelectorAll('a, button')].filter(el => {
const t = (el.textContent || '').trim().toLowerCase();
return t.includes('next ch') || t === 'next' || t.includes('next chapter');
});
const btn = candidates.find(el => !el.disabled && !el.classList.contains('disabled') && el.getAttribute('aria-disabled') !== 'true') || candidates[0];
let nextUrl = null;
if (btn) {
if (btn.tagName === 'A' && btn.href && !btn.href.includes('#/next')) nextUrl = btn.href;
else {
const parentA = btn.closest('a');
if (parentA && parentA.href && !parentA.href.includes('#/next')) nextUrl = parentA.href;
}
}
const nextLink = document.querySelector('a[rel="next"]') ||
document.querySelector('a.next') ||
document.querySelector('[class*="next"] a');
if (!nextUrl && nextLink && nextLink.href && !nextLink.href.includes('#/next')) nextUrl = nextLink.href;
if (nextUrl) {
closePanel();
location.href = nextUrl;
return;
}
if (btn) {
closePanel();
const oldUrl = location.href;
btn.click();
const check = setInterval(() => {
if (location.href !== oldUrl) {
clearInterval(check);
setTimeout(() => {
imageURLs = [];
panelOpen = false;
if (!document.getElementById('xamz-overlay')) openPanel();
}, 1600);
}
}, 250);
setTimeout(() => clearInterval(check), 12000);
return;
}
alert('Tombol Next Chapter tidak ditemukan di halaman ini');
}
// ==================== UI ====================
function applyFabPosition(btn) {
if (!btn) btn = document.getElementById('xamz-open-button');
if (!btn) return;
btn.style.left = '';
btn.style.right = '12px';
if (fabPosition === 'bottom-right') {
btn.style.top = '';
btn.style.bottom = '12px';
} else {
btn.style.bottom = '';
btn.style.top = '12px';
}
}
function createButton() {
if (document.getElementById('xamz-open-button')) return;
const btn = document.createElement('button');
btn.id = 'xamz-open-button';
btn.title = 'Pawchive Downloader';
btn.innerHTML = 'DL';
Object.assign(btn.style, {
position: 'fixed', zIndex: '2147483647',
background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '6px',
padding: '5px 10px', fontSize: '11px', fontWeight: '700', cursor: 'pointer',
boxShadow: '0 2px 10px rgba(59,130,246,.45)', letterSpacing: '0.5px',
lineHeight: '1.2', opacity: '0.92', transition: 'opacity .15s, transform .15s'
});
applyFabPosition(btn);
btn.onmouseenter = () => { btn.style.opacity = '1'; btn.style.transform = 'scale(1.05)'; };
btn.onmouseleave = () => { btn.style.opacity = '0.92'; btn.style.transform = 'scale(1)'; };
btn.onclick = openPanel;
document.body.appendChild(btn);
}
function openPanel() {
if (panelOpen && document.getElementById('xamz-overlay')) {
restorePanel();
return;
}
if (panelOpen) return;
panelOpen = true;
scanXAmz();
autoScanInterval = setInterval(() => {
if (panelOpen && !isDownloading) scanXAmz();
}, 5000);
const autoTitle = hasJapanese(getTitle()) ? toRomajiLocal(getTitle()) : getTitle();
const overlay = document.createElement('div');
overlay.id = 'xamz-overlay';
const collapsedCls = toolbarCollapsed ? 'dd-toolbar-collapsed' : '';
overlay.innerHTML = `
<div id="xamz-box" class="${isCompact ? 'dd-compact' : ''} dd-thumb-${thumbSize} ${collapsedCls}" style="--dd-accent:${accentColor};--dd-accent-hover:${lightenAccent(accentColor)}">
<div id="xamz-header">
<div class="dd-title-wrap">
<div class="dd-title">
<span class="dd-title-mark">PD</span>
<span class="dd-title-text">Pawchive</span>
<span class="dd-by">qxyraxp</span>
</div>
</div>
<div class="dd-header-actions">
<button id="dd-minimize-btn" class="dd-hbtn" title="${t('minimize')}" data-i18n-title="minimize">−</button>
<button id="dd-collapse-btn" class="dd-hbtn" title="${t('collapse')}" data-i18n-title="collapse">${toolbarCollapsed ? '▴' : '▾'}</button>
<span class="dd-hsep"></span>
<button id="dd-filter-btn" class="dd-hbtn" title="${t('filter')}" data-i18n-title="filter">⊆</button>
<button id="dd-history-btn" class="dd-hbtn" title="${t('history')}" data-i18n-title="history">☰</button>
<button id="dd-chapters-btn" class="dd-hbtn" title="${t('chapters')}" data-i18n-title="chapters">≡</button>
<button id="dd-settings-btn" class="dd-hbtn" title="${t('settings')}" data-i18n-title="settings">⚙</button>
<button id="dd-theme-btn" class="dd-hbtn" title="${t('theme')}" data-i18n-title="theme">${isDark ? '☀' : '☾'}</button>
<button id="dd-lang-btn" class="dd-hbtn dd-lang-btn" title="${t('langToggle')}" data-i18n-title="langToggle">${uiLang === 'en' ? 'EN' : 'ID'}</button>
<span class="dd-hsep"></span>
<button id="xamz-close" class="dd-hbtn dd-hbtn-close" title="${t('close')}" data-i18n-title="close">✕</button>
</div>
</div>
<div id="xamz-collapsible">
<div id="xamz-toolbar" class="dd-toolbar-row">
<button id="xamz-refresh" class="dd-tbtn" title="Refresh scan (R)">Scan</button>
<button id="dd-autoscroll" class="dd-tbtn" title="Auto Scroll">Scroll</button>
<span class="dd-hsep"></span>
<button id="xamz-select-all" class="dd-tbtn" title="Select All (A)">All</button>
<button id="xamz-select-none" class="dd-tbtn" title="Deselect All">None</button>
<button id="dd-skip-cover" class="dd-tbtn" title="Skip cover">Cover</button>
<button id="dd-skip-ends" class="dd-tbtn" title="Skip cover + ends">Ends</button>
<span class="dd-tool-spacer"></span>
<button id="dd-size-s" class="dd-tbtn dd-size-btn ${thumbSize==='s'?'dd-size-active':''}" title="Kecil">S</button>
<button id="dd-size-m" class="dd-tbtn dd-size-btn ${thumbSize==='m'?'dd-size-active':''}" title="Sedang">M</button>
<button id="dd-size-l" class="dd-tbtn dd-size-btn ${thumbSize==='l'?'dd-size-active':''}" title="Besar">L</button>
<button id="dd-compact-btn" class="dd-tbtn dd-size-btn ${isCompact?'dd-size-active':''}" title="Compact">⊞</button>
</div>
<div id="xamz-zipname-row">
<div class="dd-name-row">
<div class="dd-name-col">
<div class="dd-name-field dd-name-inline">
<label class="dd-inline-label" data-i18n="fileName">${t('fileName')}</label>
<input id="xamz-zipname" type="text" value="${autoTitle}" placeholder="${t('filePlaceholder')}" data-i18n-placeholder="filePlaceholder">
<button id="dd-use-title" type="button" title="${t('refreshTitle')}" data-i18n-title="refreshTitle">↺</button>
</div>
</div>
<div class="dd-name-col">
<div class="dd-name-field dd-name-inline">
<label class="dd-inline-label" data-i18n="saveFolder">${t('saveFolder')}</label>
<div id="dd-folder-label" class="dd-folder-label" title="${t('pickFolder')}">${t('downloadsDefault')}</div>
<button id="dd-pick-folder" type="button" title="${t('pickFolder')}" data-i18n-title="pickFolder">📁</button>
<button id="dd-clear-folder" type="button" title="${t('resetFolder')}" data-i18n="resetFolder">${t('resetFolder')}</button>
</div>
</div>
</div>
</div>
</div>
<div id="xamz-status"><span id="dd-status-text"></span></div>
<div id="dd-panel-progress" class="dd-panel-progress hidden">
<div id="dd-panel-progress-bar"></div>
</div>
<div id="xamz-grid"></div>
<!-- Bottom bar: single Download + Next Ch -->
<div id="dd-bottom-bar">
<div class="dd-bottom-left">
<span id="xamz-count" class="dd-bottom-stat dd-bottom-total">0 gambar</span>
<span class="dd-bottom-sep">·</span>
<span id="dd-bottom-selected" class="dd-bottom-stat">0 terpilih</span>
<span id="dd-bottom-size" class="dd-bottom-stat"></span>
</div>
<div class="dd-bottom-right">
<button id="dd-nextch-btn" class="dd-bottom-btn dd-next-btn" title="Next Chapter">Next Ch</button>
<div class="dd-download-wrap">
<button id="dd-download-main" class="dd-bottom-btn dd-download-main dd-dl-btn" title="Download">
Download <span class="dd-badge" id="badge-dl">0</span> ▾
</button>
<div id="dd-download-menu" class="dd-download-menu hidden">
<button id="xamz-download-selected" class="dd-menu-item" data-mode="single">Terpilih <span class="dd-menu-desc">file satu-satu</span></button>
<button id="xamz-download-zip" class="dd-menu-item ${lastDownloadMode==='zip'?'dd-menu-active':''}" data-mode="zip">ZIP <span class="dd-menu-desc">arsip zip</span></button>
<button id="xamz-download-pdf" class="dd-menu-item ${lastDownloadMode==='pdf'?'dd-menu-active':''}" data-mode="pdf">PDF <span class="dd-menu-desc">per halaman</span></button>
<button id="xamz-download-strip" class="dd-menu-item ${lastDownloadMode==='strip'?'dd-menu-active':''}" data-mode="strip">Strip <span class="dd-menu-desc">PDF nyambung</span></button>
</div>
</div>
</div>
</div>
<div class="dd-resize-edge dd-resize-n" data-dir="n"></div>
<div class="dd-resize-edge dd-resize-s" data-dir="s"></div>
<div class="dd-resize-edge dd-resize-e" data-dir="e"></div>
<div class="dd-resize-edge dd-resize-w" data-dir="w"></div>
<div class="dd-resize-corner dd-resize-nw" data-dir="nw"></div>
<div class="dd-resize-corner dd-resize-ne" data-dir="ne"></div>
<div class="dd-resize-corner dd-resize-sw" data-dir="sw"></div>
<div class="dd-resize-corner dd-resize-se" data-dir="se" title="Drag untuk ubah ukuran"></div>
<div id="dd-hover-preview" class="dd-hover-preview hidden">
<img id="dd-hover-img" alt="preview">
<div id="dd-hover-meta" class="dd-hover-meta"></div>
</div>
<div id="dd-accent-picker" class="dd-accent-picker hidden">
<button data-color="#3b82f6" style="background:#3b82f6" title="Biru"></button>
<button data-color="#6366f1" style="background:#6366f1" title="Indigo"></button>
<button data-color="#0ea5e9" style="background:#0ea5e9" title="Sky"></button>
<button data-color="#1e40af" style="background:#1e40af" title="Navy"></button>
<button data-color="#1d4ed8" style="background:#1d4ed8" title="Blue"></button>
<button data-color="#2563eb" style="background:#2563eb" title="Azure"></button>
</div>
</div>
`;
document.body.appendChild(overlay);
createStyles();
applyTheme();
const fab = document.getElementById('xamz-open-button');
if (fab) fab.style.display = 'none';
requestAnimationFrame(() => {
const box = document.getElementById('xamz-box');
if (box) box.classList.add('dd-open');
});
// Event bindings
document.getElementById('xamz-close').onclick = closePanel;
document.getElementById('dd-minimize-btn').onclick = minimizePanel;
document.getElementById('xamz-refresh').onclick = () => {
showSkeleton();
scanXAmz();
setTimeout(() => updatePanel(), 350);
};
document.getElementById('xamz-select-all').onclick = () => {
document.querySelectorAll('#xamz-grid input[type="checkbox"]').forEach(cb => cb.checked = true);
updateSelectedUI();
};
document.getElementById('xamz-select-none').onclick = () => {
document.querySelectorAll('#xamz-grid input[type="checkbox"]').forEach(cb => cb.checked = false);
updateSelectedUI();
};
document.getElementById('dd-skip-cover').onclick = skipCover;
document.getElementById('dd-skip-ends').onclick = skipCoverAndEnd;
document.getElementById('xamz-download-selected').onclick = () => { closeDownloadMenu(); downloadSelected(); };
document.getElementById('xamz-download-zip').onclick = () => { closeDownloadMenu(); lastDownloadMode = 'zip'; localStorage.setItem('dd_last_mode', 'zip'); updateMenuActive(); downloadAsZip(); };
document.getElementById('xamz-download-pdf').onclick = () => { closeDownloadMenu(); lastDownloadMode = 'pdf'; localStorage.setItem('dd_last_mode', 'pdf'); updateMenuActive(); downloadAsPdf(); };
document.getElementById('xamz-download-strip').onclick = () => { closeDownloadMenu(); lastDownloadMode = 'strip'; localStorage.setItem('dd_last_mode', 'strip'); updateMenuActive(); downloadAsPdfStrip(); };
document.getElementById('dd-history-btn').onclick = showHistory;
document.getElementById('dd-chapters-btn').onclick = showChapterPanel;
const logBtn = document.getElementById('dd-log-btn');
if (logBtn) logBtn.onclick = showLogPanel;
document.getElementById('dd-settings-btn').onclick = showSettings;
const helpBtn = document.getElementById('dd-help-btn');
if (helpBtn) helpBtn.onclick = showHelp;
document.getElementById('dd-theme-btn').onclick = toggleTheme;
const langBtn = document.getElementById('dd-lang-btn');
if (langBtn) langBtn.onclick = toggleLang;
document.getElementById('dd-autoscroll').onclick = autoScrollScan;
const reloadBtn = document.getElementById('dd-reload-btn');
if (reloadBtn) reloadBtn.onclick = () => location.reload();
document.getElementById('dd-nextch-btn').onclick = goNextChapter;
document.getElementById('dd-use-title').onclick = () => {
fillZipNameRomaji();
};
document.getElementById('dd-pick-folder').onclick = () => { pickSaveFolder(); };
document.getElementById('dd-clear-folder').onclick = async () => {
await clearDirHandle();
saveMode = 'default';
localStorage.setItem('dd_save_mode', 'default');
updateFolderUI();
const st = document.getElementById('dd-status-text');
if (st) st.textContent = 'Simpan ke Downloads (default)';
};
restoreDirHandle().then(() => updateFolderUI());
// Download dropdown
document.getElementById('dd-download-main').onclick = (e) => {
e.stopPropagation();
if (isDownloading) return;
const menu = document.getElementById('dd-download-menu');
if (menu) menu.classList.toggle('hidden');
};
document.getElementById('dd-size-s').onclick = () => setThumbSize('s');
document.getElementById('dd-size-m').onclick = () => setThumbSize('m');
document.getElementById('dd-size-l').onclick = () => setThumbSize('l');
document.getElementById('dd-compact-btn').onclick = toggleCompact;
document.getElementById('dd-collapse-btn').onclick = toggleToolbarCollapse;
document.getElementById('dd-filter-btn').onclick = toggleSelectedOnly;
const accentBtn = document.getElementById('dd-accent-btn');
if (accentBtn) {
accentBtn.onclick = (e) => {
e.stopPropagation();
document.getElementById('dd-accent-picker')?.classList.toggle('hidden');
};
}
document.querySelectorAll('#dd-accent-picker button').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
setAccentColor(btn.dataset.color);
document.getElementById('dd-accent-picker')?.classList.add('hidden');
};
});
document.getElementById('xamz-box')?.addEventListener('click', (e) => {
if (!e.target.closest('#dd-accent-btn') && !e.target.closest('#dd-accent-picker')) {
document.getElementById('dd-accent-picker')?.classList.add('hidden');
}
if (!e.target.closest('#dd-download-main') && !e.target.closest('#dd-download-menu')) {
closeDownloadMenu();
}
});
document.addEventListener('keydown', handleKeydown);
updatePanel();
initPanelResize();
initPanelDrag();
applyDownloadDisabledState();
applyI18n();
// Judul Jepang → romaji (async, overwrite setelah Google respond)
fillZipNameRomaji();
}
function handleKeydown(e) {
if (!panelOpen) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
const key = e.key.toLowerCase();
if (key === 'escape') {
e.preventDefault();
const modal = document.getElementById('dd-settings-box') || document.getElementById('dd-help-box') || document.getElementById('dd-history-box') || document.getElementById('dd-chapter-box') || document.getElementById('dd-log-box');
if (modal) modal.remove();
else closePanel();
} else if (key === 'r' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
document.getElementById('xamz-refresh')?.click();
} else if (key === 'a' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
document.getElementById('xamz-select-all')?.click();
} else if (key === 'z' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
document.getElementById('xamz-download-zip')?.click();
} else if (key === '?' || (key === '/' && e.shiftKey)) {
e.preventDefault();
showHelp();
} else if (key === 's' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
showSettings();
}
}
function skipCover() {
const n = Math.max(0, skipCoverCount | 0);
const checkboxes = [...document.querySelectorAll('#xamz-grid input[type="checkbox"]')];
checkboxes.forEach((cb, i) => {
cb.checked = i >= n;
});
updateSelectedUI();
}
// Uncheck cover (awal) + credit/end page (akhir)
function skipCoverAndEnd() {
const nStart = Math.max(0, skipCoverCount | 0);
const nEnd = 1; // 1 panel terakhir (biasanya credit)
const checkboxes = [...document.querySelectorAll('#xamz-grid input[type="checkbox"]')];
const total = checkboxes.length;
if (!total) return;
checkboxes.forEach((cb, i) => {
const isCover = i < nStart;
const isEnd = i >= total - nEnd;
cb.checked = !isCover && !isEnd;
});
updateSelectedUI();
const st = document.getElementById('dd-status-text');
if (st) {
const skipped = nStart + Math.min(nEnd, Math.max(0, total - nStart));
st.textContent = `Skip ${nStart} cover + ${Math.min(nEnd, total)} akhir · ${getSelectedIndexes().length} terpilih`;
}
}
function showHelp() {
if (document.getElementById('dd-help-box')) return;
const box = document.createElement('div');
box.id = 'dd-help-box';
box.innerHTML = `
<div class="dd-modal-bg">
<div class="dd-modal">
<div class="dd-modal-head">
<b>⌨ Shortcut Keyboard</b>
<button id="dd-help-close" type="button">✕</button>
</div>
<div class="dd-modal-body">
<div class="dd-help-row"><kbd>R</kbd> <span>Refresh scan gambar</span></div>
<div class="dd-help-row"><kbd>A</kbd> <span>Pilih semua panel</span></div>
<div class="dd-help-row"><kbd>Z</kbd> <span>Download sebagai ZIP</span></div>
<div class="dd-help-row"><kbd>S</kbd> <span>Buka Settings</span></div>
<div class="dd-help-row"><kbd>?</kbd> <span>Tampilkan bantuan ini</span></div>
<div class="dd-help-row"><kbd>Esc</kbd> <span>Tutup panel / modal</span></div>
<div class="dd-help-row"><kbd>Shift+Click</kbd> <span>Pilih range panel</span></div>
<div class="dd-help-row"><kbd>⏭ Cover</kbd> <span>Uncheck panel awal (jumlah di Settings)</span></div>
<div class="dd-help-row"><kbd>⏭ Ends</kbd> <span>Uncheck cover + 1 panel terakhir (credit)</span></div>
<div class="dd-help-note">Saat download, title tab browser menampilkan progress (bisa dimatikan di Settings).</div>
</div>
</div>
</div>`;
document.body.appendChild(box);
document.getElementById('dd-help-close').onclick = () => box.remove();
box.querySelector('.dd-modal-bg').onclick = (e) => { if (e.target === e.currentTarget) box.remove(); };
}
function showSettings() {
if (document.getElementById('dd-settings-box')) return;
const box = document.createElement('div');
box.id = 'dd-settings-box';
box.innerHTML = `
<div class="dd-modal-bg">
<div class="dd-modal">
<div class="dd-modal-head">
<b>⚙ ${t('settings')}</b>
<button id="dd-settings-close" type="button">✕</button>
</div>
<div class="dd-modal-body">
<label class="dd-set-label">${t('language')}
<select id="dd-set-lang">
<option value="en"${uiLang==='en'?' selected':''}>English</option>
<option value="id"${uiLang==='id'?' selected':''}>Indonesia</option>
</select>
</label>
<label class="dd-set-label">${t('concurrent')}
<select id="dd-set-concurrency">
<option value="2"${dlConcurrency===2?' selected':''}>2</option>
<option value="3"${dlConcurrency===3?' selected':''}>3</option>
<option value="4"${dlConcurrency===4?' selected':''}>4 (default)</option>
<option value="5"${dlConcurrency===5?' selected':''}>5</option>
<option value="6"${dlConcurrency===6?' selected':''}>6</option>
</select>
</label>
<label class="dd-set-label">${t('jpegQuality')}
<select id="dd-set-jpeg">
<option value="0.8"${jpegQuality===0.8?' selected':''}>80%</option>
<option value="0.85"${jpegQuality===0.85?' selected':''}>85%</option>
<option value="0.92"${Math.abs(jpegQuality-0.92)<0.01?' selected':''}>92% (default)</option>
<option value="0.95"${jpegQuality===0.95?' selected':''}>95%</option>
<option value="1"${jpegQuality===1?' selected':''}>100%</option>
</select>
</label>
<label class="dd-set-label">${t('defaultMode')}
<select id="dd-set-mode">
<option value="zip"${lastDownloadMode==='zip'?' selected':''}>ZIP</option>
<option value="pdf"${lastDownloadMode==='pdf'?' selected':''}>PDF</option>
<option value="strip"${lastDownloadMode==='strip'?' selected':''}>Strip PDF</option>
<option value="single"${lastDownloadMode==='single'?' selected':''}>${t('selected')}</option>
</select>
</label>
<label class="dd-set-label">${t('skipCover')}
<select id="dd-set-skip">
<option value="0"${skipCoverCount===0?' selected':''}>0</option>
<option value="1"${skipCoverCount===1?' selected':''}>1</option>
<option value="2"${skipCoverCount===2?' selected':''}>2 (default)</option>
<option value="3"${skipCoverCount===3?' selected':''}>3</option>
<option value="4"${skipCoverCount===4?' selected':''}>4</option>
</select>
</label>
<label class="dd-set-label">${t('fabPos')}
<select id="dd-set-fab">
<option value="top-right"${fabPosition==='top-right'?' selected':''}>${t('topRight')}</option>
<option value="bottom-right"${fabPosition==='bottom-right'?' selected':''}>${t('bottomRight')}</option>
</select>
</label>
<label class="dd-set-label">${t('saveLoc')}
<select id="dd-set-savemode">
<option value="default"${saveMode==='default'?' selected':''}>${t('saveDefault')}</option>
<option value="ask"${saveMode==='ask'?' selected':''}>${t('saveAsk')}</option>
<option value="folder"${saveMode==='folder'?' selected':''}>${t('saveFolderMode')}</option>
</select>
</label>
<div class="dd-help-note" style="margin:0 0 12px;">${t('saveNote')}</div>
<label class="dd-set-check"><input type="checkbox" id="dd-set-autoopen"${autoOpenPanel?' checked':''}> ${t('autoOpen')}</label>
<label class="dd-set-check"><input type="checkbox" id="dd-set-tabprog"${tabProgressEnabled?' checked':''}> ${t('tabProg')}</label>
<label class="dd-set-check"><input type="checkbox" id="dd-set-automin"${autoMinimizeOnDl?' checked':''}> ${t('autoMin')}</label>
<label class="dd-set-check"><input type="checkbox" id="dd-set-notify"${notifyEnabled?' checked':''}> ${t('notify')}</label>
<div class="dd-set-actions">
<button type="button" id="dd-settings-save" class="dd-set-save">${t('save')}</button>
<button type="button" id="dd-settings-reset" class="dd-set-reset">${t('resetDefault')}</button>
<button type="button" id="dd-settings-log" class="dd-set-reset">${t('log')}</button>
<button type="button" id="dd-settings-help" class="dd-set-reset">${t('help')}</button>
</div>
</div>
</div>
</div>`;
document.body.appendChild(box);
document.getElementById('dd-settings-close').onclick = () => box.remove();
box.querySelector('.dd-modal-bg').onclick = (e) => { if (e.target === e.currentTarget) box.remove(); };
document.getElementById('dd-settings-log').onclick = () => { box.remove(); showLogPanel(); };
document.getElementById('dd-settings-help').onclick = () => { box.remove(); showHelp(); };
document.getElementById('dd-settings-save').onclick = () => {
const newLang = document.getElementById('dd-set-lang')?.value || 'en';
uiLang = (newLang === 'id') ? 'id' : 'en';
localStorage.setItem('dd_lang', uiLang);
dlConcurrency = parseInt(document.getElementById('dd-set-concurrency').value, 10) || 4;
jpegQuality = parseFloat(document.getElementById('dd-set-jpeg').value) || 0.92;
lastDownloadMode = document.getElementById('dd-set-mode').value || 'zip';
skipCoverCount = parseInt(document.getElementById('dd-set-skip').value, 10) || 0;
fabPosition = document.getElementById('dd-set-fab').value || 'top-right';
autoOpenPanel = document.getElementById('dd-set-autoopen').checked;
tabProgressEnabled = document.getElementById('dd-set-tabprog').checked;
autoMinimizeOnDl = document.getElementById('dd-set-automin').checked;
notifyEnabled = document.getElementById('dd-set-notify').checked;
applyI18n();
const newSaveMode = document.getElementById('dd-set-savemode').value || 'default';
if (newSaveMode !== 'folder' && saveMode === 'folder') {
clearDirHandle();
}
saveMode = newSaveMode;
localStorage.setItem('dd_concurrency', String(dlConcurrency));
localStorage.setItem('dd_jpeg_q', String(jpegQuality));
localStorage.setItem('dd_last_mode', lastDownloadMode);
localStorage.setItem('dd_skip_cover', String(skipCoverCount));
localStorage.setItem('dd_fab_pos', fabPosition);
localStorage.setItem('dd_auto_open', autoOpenPanel ? '1' : '0');
localStorage.setItem('dd_tab_progress', tabProgressEnabled ? '1' : '0');
localStorage.setItem('dd_auto_minimize', autoMinimizeOnDl ? '1' : '0');
localStorage.setItem('dd_notify', notifyEnabled ? '1' : '0');
localStorage.setItem('dd_save_mode', saveMode);
applyFabPosition();
updateMenuActive();
updateFolderUI();
box.remove();
const st = document.getElementById('dd-status-text');
if (st) st.textContent = 'Settings disimpan';
setTimeout(() => { if (st && !isDownloading) updateSelectedUI(); }, 1500);
};
document.getElementById('dd-settings-reset').onclick = async () => {
localStorage.removeItem('dd_concurrency');
localStorage.removeItem('dd_jpeg_q');
localStorage.removeItem('dd_skip_cover');
localStorage.removeItem('dd_fab_pos');
localStorage.removeItem('dd_auto_open');
localStorage.removeItem('dd_tab_progress');
localStorage.removeItem('dd_auto_minimize');
localStorage.removeItem('dd_notify');
localStorage.removeItem('dd_save_mode');
await clearDirHandle();
dlConcurrency = 4;
jpegQuality = 0.92;
skipCoverCount = 2;
fabPosition = 'top-right';
autoOpenPanel = true;
tabProgressEnabled = true;
autoMinimizeOnDl = false;
notifyEnabled = true;
saveMode = 'default';
applyFabPosition();
box.remove();
showSettings();
};
}
function closeDownloadMenu() {
document.getElementById('dd-download-menu')?.classList.add('hidden');
}
function updateMenuActive() {
document.querySelectorAll('#dd-download-menu .dd-menu-item').forEach(btn => {
const mode = btn.dataset.mode;
btn.classList.toggle('dd-menu-active', mode === lastDownloadMode);
});
}
function lightenAccent(hex) {
try {
const n = parseInt(hex.slice(1), 16);
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
r = Math.min(255, r + 40); g = Math.min(255, g + 40); b = Math.min(255, b + 40);
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
} catch (e) { return '#60a5fa'; }
}
function setAccentColor(color) {
accentColor = color;
localStorage.setItem('dd_accent', color);
const box = document.getElementById('xamz-box');
if (box) {
box.style.setProperty('--dd-accent', color);
box.style.setProperty('--dd-accent-hover', lightenAccent(color));
}
}
function toggleToolbarCollapse() {
toolbarCollapsed = !toolbarCollapsed;
localStorage.setItem('dd_toolbar_collapsed', toolbarCollapsed ? '1' : '0');
const box = document.getElementById('xamz-box');
if (box) box.classList.toggle('dd-toolbar-collapsed', toolbarCollapsed);
const btn = document.getElementById('dd-collapse-btn');
if (btn) btn.textContent = toolbarCollapsed ? '▴' : '▾';
}
function toggleSelectedOnly() {
showSelectedOnly = !showSelectedOnly;
const btn = document.getElementById('dd-filter-btn');
if (btn) {
btn.classList.toggle('dd-filter-active', showSelectedOnly);
btn.title = showSelectedOnly ? 'Filter ON — tampilkan semua' : 'Tampilkan hanya terpilih';
}
applySelectedOnlyFilter();
}
function applySelectedOnlyFilter() {
document.querySelectorAll('#xamz-grid .xamz-item').forEach(item => {
const cb = item.querySelector('input[type="checkbox"]');
if (!cb) return;
if (showSelectedOnly && !cb.checked) {
item.classList.add('dd-filtered-out');
} else {
item.classList.remove('dd-filtered-out');
}
});
}
function setThumbSize(size) {
thumbSize = size;
localStorage.setItem('dd_thumb', size);
const box = document.getElementById('xamz-box');
if (box) {
box.classList.remove('dd-thumb-s', 'dd-thumb-m', 'dd-thumb-l');
box.classList.add('dd-thumb-' + size);
}
['s', 'm', 'l'].forEach(s => {
const btn = document.getElementById('dd-size-' + s);
if (btn) btn.classList.toggle('dd-size-active', s === size);
});
}
function toggleCompact() {
isCompact = !isCompact;
localStorage.setItem('dd_compact', isCompact ? '1' : '0');
const box = document.getElementById('xamz-box');
if (box) box.classList.toggle('dd-compact', isCompact);
const btn = document.getElementById('dd-compact-btn');
if (btn) btn.classList.toggle('dd-size-active', isCompact);
}
function applyDownloadDisabledState() {
document.querySelectorAll('.dd-dl-btn, .dd-menu-item').forEach(btn => {
btn.disabled = isDownloading;
btn.classList.toggle('dd-btn-disabled', isDownloading);
});
if (isDownloading) closeDownloadMenu();
}
function beginDownload() {
isDownloading = true;
downloadStartTime = Date.now();
downloadBytesDone = 0;
originalDocTitle = document.title;
applyDownloadDisabledState();
if (tabProgressEnabled) document.title = '⏳ 0% · DL';
if (autoMinimizeOnDl && panelOpen) {
try { minimizePanel(); } catch (e) {}
}
ddLog('info', 'Download mulai', `${getSelectedIndexes().length} panel · mode ${lastDownloadMode}`);
}
function endDownload() {
isDownloading = false;
downloadStartTime = 0;
downloadBytesDone = 0;
applyDownloadDisabledState();
if (tabProgressEnabled) {
document.title = '✅ Selesai · DL';
setTimeout(restoreDocTitle, 2500);
}
ddLog('info', 'Download selesai');
}
function updateSelectedUI() {
const selected = getSelectedIndexes().length;
const total = imageURLs.length;
const selCount = document.getElementById('xamz-selected-count');
if (selCount) {
selCount.textContent = selected + ' ' + t('selectedCount');
selCount.classList.toggle('dd-has-selected', selected > 0);
}
const bottomTotal = document.getElementById('xamz-count');
if (bottomTotal) bottomTotal.textContent = total + ' ' + t('images');
const bottomSel = document.getElementById('dd-bottom-selected');
if (bottomSel) bottomSel.textContent = selected + ' ' + t('selectedCount');
const bottomSize = document.getElementById('dd-bottom-size');
if (bottomSize) {
if (selected > 0) {
bottomSize.textContent = '≈ ' + formatBytes(getEstimatedSize(getSelectedIndexes()));
} else {
bottomSize.textContent = '';
}
}
const badgeDl = document.getElementById('badge-dl');
if (badgeDl) {
badgeDl.textContent = selected;
badgeDl.style.display = selected > 0 ? '' : 'none';
}
const badges = ['badge-selected', 'badge-zip', 'badge-pdf', 'badge-strip'];
badges.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.textContent = selected;
el.style.display = selected > 0 ? '' : 'none';
}
});
updateSizeEstimate();
const statusText = document.getElementById('dd-status-text');
if (statusText && !isDownloading) {
if (total === 0) statusText.textContent = '';
else {
statusText.textContent = t('selectedOf')
.replace('{s}', selected)
.replace('{t}', total) + ' • ' + t('statusHint');
}
}
if (showSelectedOnly) applySelectedOnlyFilter();
}
function closePanel() {
document.removeEventListener('keydown', handleKeydown);
const overlay = document.getElementById('xamz-overlay');
const box = document.getElementById('xamz-box');
if (box) {
box.classList.remove('dd-open');
box.classList.add('dd-closing');
}
setTimeout(() => {
overlay?.remove();
panelOpen = false;
if (autoScanInterval) {
clearInterval(autoScanInterval);
autoScanInterval = null;
}
if (!isDownloading) hideMiniProgress();
const fab = document.getElementById('xamz-open-button');
if (fab) fab.style.display = '';
}, 200);
}
function toggleTheme() {
isDark = !isDark;
localStorage.setItem('dd_theme', isDark ? 'dark' : 'light');
applyTheme();
const btn = document.getElementById('dd-theme-btn');
if (btn) {
btn.textContent = isDark ? '☀' : '☾';
btn.title = isDark ? 'Mode Light' : 'Mode Dark';
}
}
function applyTheme() {
const root = document.getElementById('xamz-box');
if (!root) return;
if (isDark) {
root.classList.remove('dd-light');
root.classList.add('dd-dark');
} else {
root.classList.remove('dd-dark');
root.classList.add('dd-light');
}
}
// ==================== Panel Resize + Drag ====================
function applySavedPanelSize() {
const box = document.getElementById('xamz-box');
if (!box) return;
try {
const saved = JSON.parse(localStorage.getItem('dd_panel_size') || 'null');
if (saved && saved.w && saved.h) {
box.style.left = 'auto';
box.style.right = 'auto';
box.style.top = 'auto';
box.style.bottom = 'auto';
box.style.width = saved.w + 'px';
box.style.height = saved.h + 'px';
if (typeof saved.x === 'number' && typeof saved.y === 'number') {
box.style.left = Math.max(8, saved.x) + 'px';
box.style.top = Math.max(8, saved.y) + 'px';
} else {
const left = Math.max(8, (window.innerWidth - saved.w) / 2);
const top = Math.max(8, (window.innerHeight - saved.h) / 2);
box.style.left = left + 'px';
box.style.top = top + 'px';
}
}
} catch (e) {}
}
function savePanelSize() {
const box = document.getElementById('xamz-box');
if (!box) return;
const rect = box.getBoundingClientRect();
localStorage.setItem('dd_panel_size', JSON.stringify({
w: Math.round(rect.width),
h: Math.round(rect.height),
x: Math.round(rect.left),
y: Math.round(rect.top)
}));
}
function initPanelResize() {
const box = document.getElementById('xamz-box');
if (!box) return;
applySavedPanelSize();
const handles = box.querySelectorAll('.dd-resize-edge, .dd-resize-corner');
if (!handles.length) return;
let resizing = false;
let dir = '';
let startX, startY, startW, startH, startL, startT;
const cursorMap = {
n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize',
nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize'
};
handles.forEach(handle => {
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizing = true;
dir = handle.dataset.dir || 'se';
const rect = box.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startW = rect.width;
startH = rect.height;
startL = rect.left;
startT = rect.top;
box.style.left = startL + 'px';
box.style.top = startT + 'px';
box.style.right = 'auto';
box.style.bottom = 'auto';
box.style.width = startW + 'px';
box.style.height = startH + 'px';
document.body.style.userSelect = 'none';
document.body.style.cursor = cursorMap[dir] || 'nwse-resize';
});
handle.addEventListener('dblclick', () => {
box.style.left = '2.5%';
box.style.right = '2.5%';
box.style.top = '2.5%';
box.style.bottom = '2.5%';
box.style.width = '';
box.style.height = '';
localStorage.removeItem('dd_panel_size');
});
});
window.addEventListener('mousemove', (e) => {
if (!resizing) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const minW = 420;
const minH = 300;
const maxW = window.innerWidth - 8;
const maxH = window.innerHeight - 8;
let newW = startW, newH = startH, newL = startL, newT = startT;
if (dir.includes('e')) newW = Math.min(maxW, Math.max(minW, startW + dx));
if (dir.includes('s')) newH = Math.min(maxH, Math.max(minH, startH + dy));
if (dir.includes('w')) {
newW = Math.min(maxW, Math.max(minW, startW - dx));
newL = startL + (startW - newW);
}
if (dir.includes('n')) {
newH = Math.min(maxH, Math.max(minH, startH - dy));
newT = startT + (startH - newH);
}
// jaga tetap di dalam viewport
if (newL < 0) { newW += newL; newL = 0; }
if (newT < 0) { newH += newT; newT = 0; }
if (newL + newW > window.innerWidth) newW = window.innerWidth - newL;
if (newT + newH > window.innerHeight) newH = window.innerHeight - newT;
box.style.width = Math.max(minW, newW) + 'px';
box.style.height = Math.max(minH, newH) + 'px';
box.style.left = Math.max(0, newL) + 'px';
box.style.top = Math.max(0, newT) + 'px';
});
window.addEventListener('mouseup', () => {
if (!resizing) return;
resizing = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
savePanelSize();
});
}
function initPanelDrag() {
const box = document.getElementById('xamz-box');
const header = document.getElementById('xamz-header');
if (!box || !header) return;
let startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
isDraggingPanel = true;
const rect = box.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
box.style.left = startLeft + 'px';
box.style.top = startTop + 'px';
box.style.right = 'auto';
box.style.bottom = 'auto';
box.style.width = rect.width + 'px';
box.style.height = rect.height + 'px';
document.body.style.userSelect = 'none';
header.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDraggingPanel) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
box.style.left = Math.max(0, startLeft + dx) + 'px';
box.style.top = Math.max(0, startTop + dy) + 'px';
});
window.addEventListener('mouseup', () => {
if (!isDraggingPanel) return;
isDraggingPanel = false;
document.body.style.userSelect = '';
header.style.cursor = 'grab';
savePanelSize();
});
header.style.cursor = 'grab';
}
function createStyles() {
if (document.getElementById('xamz-style')) return;
const style = document.createElement('style');
style.id = 'xamz-style';
style.textContent = `
@keyframes ddIn {
from { opacity: 0; transform: scale(0.96) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes ddOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.96) translateY(10px); }
}
@keyframes ddSpin {
to { transform: rotate(360deg); }
}
.dd-spinner {
display: inline-block;
width: 12px; height: 12px;
border: 2px solid rgba(59,130,246,.25);
border-top-color: #3b82f6;
border-radius: 50%;
animation: ddSpin .7s linear infinite;
vertical-align: -2px;
margin-right: 4px;
flex-shrink: 0;
}
#dd-mini-title {
display: flex; align-items: center; gap: 2px;
}
#xamz-box {
--dd-bg: #08080c;
--dd-bg-header: #0c0c14;
--dd-bg-toolbar: #0a0a12;
--dd-bg-input-row: #08080e;
--dd-bg-grid: #050508;
--dd-bg-item: #0e0e14;
--dd-bg-input: #0f0f16;
--dd-text: #ffffff;
--dd-text-muted: #93c5fd;
--dd-accent: #3b82f6;
--dd-accent-hover: #60a5fa;
--dd-border: rgba(59,130,246,.45);
--dd-border-soft: rgba(59,130,246,.25);
--dd-shadow: 0 0 28px rgba(59,130,246,.22);
--dd-item-border: rgba(59,130,246,.35);
}
#xamz-box.dd-light {
--dd-bg: #eff6ff;
--dd-bg-header: #dbeafe;
--dd-bg-toolbar: #dbeafe;
--dd-bg-input-row: #eff6ff;
--dd-bg-grid: #f0f9ff;
--dd-bg-item: #ffffff;
--dd-bg-input: #ffffff;
--dd-text: #1a1a1a;
--dd-text-muted: #1d4ed8;
--dd-accent: #3b82f6;
--dd-accent-hover: #60a5fa;
--dd-border: rgba(59,130,246,.5);
--dd-border-soft: rgba(59,130,246,.3);
--dd-shadow: 0 0 28px rgba(59,130,246,.15);
--dd-item-border: rgba(59,130,246,.4);
}
#xamz-overlay{
position:fixed;inset:0;z-index:2147483646;
background:rgba(0,0,0,.82);
font-family:'Segoe UI',Arial,sans-serif;
}
#xamz-box{
position:absolute;left:2.5%;right:2.5%;top:2.5%;bottom:2.5%;
background:var(--dd-bg);
color:var(--dd-text);
border:1.5px solid var(--dd-accent);
border-radius:14px;
display:flex;flex-direction:column;overflow:hidden;
box-shadow:var(--dd-shadow);
opacity:0;transform:scale(0.96) translateY(10px);
}
#xamz-box.dd-open{animation:ddIn .28s cubic-bezier(.22,1,.36,1) forwards}
#xamz-box.dd-closing{animation:ddOut .22s ease-in forwards}
#xamz-header{
min-height:48px;display:flex;align-items:center;justify-content:space-between;
padding:0 12px;gap:10px;
background:var(--dd-bg-header);
border-bottom:1px solid var(--dd-border-soft);
position:sticky;top:0;z-index:5;
user-select:none;
}
.dd-title-wrap{display:flex;align-items:center;gap:8px;min-width:0}
.dd-title{
display:flex;align-items:center;gap:8px;
font-size:13px;line-height:1.2;
}
.dd-title-mark{
display:inline-flex;align-items:center;justify-content:center;
width:24px;height:24px;border-radius:6px;
background:var(--dd-accent);
color:#fff;font-weight:800;font-size:10px;letter-spacing:.4px;
}
.dd-title-text{
font-weight:700;font-size:14px;letter-spacing:.2px;
color:var(--dd-text);
}
.dd-title-sub{display:none}
.dd-by{
font-size:10px;font-weight:500;color:var(--dd-text-muted);
opacity:.7;letter-spacing:.2px;
}
#xamz-box.dd-light .dd-title-text{color:#0f172a}
.dd-hsep{
width:1px;height:16px;background:var(--dd-border-soft);
margin:0 4px;flex-shrink:0;
}
.dd-tool-spacer{flex:1}
.dd-count{
font-size:11px;color:var(--dd-text-muted);background:rgba(59,130,246,.15);
padding:2px 9px;border-radius:999px;border:1px solid var(--dd-border-soft);
}
.dd-selected-count{
font-size:11px;color:var(--dd-text-muted);background:rgba(59,130,246,.08);
padding:2px 9px;border-radius:999px;border:1px solid transparent;
transition: all .15s;
}
.dd-selected-count.dd-has-selected{
color:#000;background:var(--dd-accent);border-color:var(--dd-accent);
font-weight:700;
}
.dd-size-estimate{
font-size:11px;color:var(--dd-text-muted);
background:rgba(59,130,246,.1);
padding:2px 9px;border-radius:999px;
border:1px solid var(--dd-border-soft);
font-weight:600;
}
.dd-badge{
display:inline-block;min-width:16px;padding:1px 5px;margin-left:3px;
font-size:10px;font-weight:800;border-radius:999px;
background:rgba(0,0,0,.25);color:#fff;line-height:1.3;
}
.dd-size-btn{
min-width:28px !important;padding:6px 8px !important;
font-size:12px !important;
}
.dd-size-btn.dd-size-active{
background:var(--dd-accent-hover) !important;
box-shadow:0 0 0 2px rgba(59,130,246,.4);
}
.dd-mode-active{
box-shadow:0 0 0 2px rgba(255,255,255,.35) !important;
}
.dd-panel-progress{
height:5px;background:rgba(59,130,246,.15);overflow:hidden;
}
.dd-panel-progress.hidden{display:none}
#dd-panel-progress-bar{
height:100%;width:0%;background:linear-gradient(90deg,var(--dd-accent),var(--dd-accent-hover));
transition:width .2s;
}
.dd-header-actions{
display:flex;align-items:center;gap:2px;flex-shrink:0;
}
.dd-hbtn-group{
display:contents;
}
.dd-header-actions .dd-hbtn{
background:transparent;color:var(--dd-text-muted);border:none;border-radius:6px;
width:28px;height:28px;min-width:28px;padding:0;cursor:pointer;
font-weight:600;font-size:13px;line-height:1;
display:flex;align-items:center;justify-content:center;
transition:background .15s, color .15s, transform .12s;
}
.dd-header-actions .dd-hbtn:hover{
background:rgba(59,130,246,.2);color:var(--dd-text);
}
.dd-header-actions .dd-hbtn.dd-filter-active,
.dd-header-actions .dd-hbtn.dd-size-active{
background:var(--dd-accent);color:#fff;
}
.dd-header-actions .dd-hbtn-close:hover{
background:#ef4444;color:#fff;
}
.dd-lang-btn{
font-size:11px !important;font-weight:700 !important;
letter-spacing:.3px;min-width:32px !important;
}
.dd-toolbar-row{
padding:6px 12px;display:flex;flex-wrap:wrap;gap:4px;align-items:center;
background:var(--dd-bg-toolbar);border-bottom:1px solid var(--dd-border-soft);
}
.dd-toolbar-dl{background:var(--dd-bg-toolbar);}
.dd-tool-group{display:contents}
.dd-tool-size{display:contents}
.dd-toolbar-row .dd-tbtn{
background:transparent;color:var(--dd-text-muted);border:none;border-radius:6px;
padding:5px 10px;cursor:pointer;font-weight:600;font-size:12px;
transition:background .12s, color .12s;
white-space:nowrap;line-height:1.2;
}
.dd-toolbar-row .dd-tbtn:hover{
background:rgba(59,130,246,.18);color:var(--dd-text);
}
.dd-toolbar-row .dd-size-btn{
min-width:28px;padding:5px 8px;text-align:center;
}
.dd-toolbar-row .dd-size-btn.dd-size-active{
background:var(--dd-accent);color:#fff;
}
#xamz-zipname-row{
padding:8px 12px;
background:var(--dd-bg-input-row);
border-bottom:1px solid var(--dd-border-soft);
}
.dd-name-row{
display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;
}
.dd-name-col{
flex:1 1 200px;min-width:0;display:flex;flex-direction:column;gap:0;
}
.dd-name-field{display:flex;gap:6px;align-items:center;}
.dd-name-inline{width:100%}
.dd-inline-label{
flex-shrink:0;font-size:11px;font-weight:600;color:var(--dd-text-muted);
white-space:nowrap;min-width:72px;
}
#xamz-zipname{
flex:1;min-width:0;box-sizing:border-box;
padding:7px 10px;border-radius:6px;
border:1px solid var(--dd-border-soft);
background:var(--dd-bg-input);color:var(--dd-text);font-size:13px;
}
#xamz-zipname:focus{
outline:none;border-color:var(--dd-accent);
box-shadow:0 0 0 2px rgba(59,130,246,.15);
}
.dd-name-field button{
flex-shrink:0;padding:7px 9px;border-radius:6px;border:none;
background:var(--dd-accent);color:#fff;font-weight:600;cursor:pointer;font-size:12px;
}
#dd-clear-folder{
background:transparent !important;color:var(--dd-text-muted) !important;
border:1px solid var(--dd-border-soft) !important;
}
.dd-folder-label{
flex:1;min-width:0;box-sizing:border-box;
padding:8px 11px;border-radius:8px;
border:1px solid var(--dd-border-soft);
background:var(--dd-bg-input);color:var(--dd-text-muted);
font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.dd-folder-label.dd-folder-set{
color:var(--dd-text);border-color:var(--dd-accent);
background:rgba(59,130,246,.12);
}
#xamz-status{
min-height:18px;padding:4px 12px;
color:var(--dd-text-muted);font-size:11px;
}
/* ===== BOTTOM BAR + DOWNLOAD MENU ===== */
#dd-bottom-bar{
display:flex;align-items:center;justify-content:space-between;
gap:12px;padding:10px 14px;
background:var(--dd-bg-header);
border-top:1px solid var(--dd-border);
flex-shrink:0;
}
.dd-bottom-left{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.dd-bottom-sep{opacity:.35;font-weight:700;}
.dd-bottom-total{
color:var(--dd-accent) !important;
border-color:rgba(59,130,246,.35) !important;
}
.dd-bottom-stat{
font-size:12px;color:var(--dd-text-muted);
background:rgba(59,130,246,.1);
padding:4px 10px;border-radius:999px;
border:1px solid var(--dd-border-soft);
font-weight:600;
}
.dd-bottom-right{display:flex;align-items:center;gap:8px;}
.dd-bottom-btn{
background:var(--dd-accent);color:#000;border:none;border-radius:8px;
padding:9px 16px;cursor:pointer;font-weight:700;font-size:13px;
transition:background .15s, transform .15s, box-shadow .15s;
white-space:nowrap;
}
.dd-bottom-btn:hover{
background:var(--dd-accent-hover);transform:translateY(-1px);
box-shadow:0 4px 12px rgba(59,130,246,.3);
}
.dd-next-btn{
background:transparent;color:var(--dd-text-muted);
border:1px solid var(--dd-border-soft);
}
.dd-next-btn:hover{
background:rgba(59,130,246,.15);color:var(--dd-text);
border-color:var(--dd-accent);
}
.dd-download-main{
min-width:140px;
}
.dd-download-wrap{
position:relative;
}
.dd-download-menu{
position:absolute;bottom:calc(100% + 6px);right:0;
min-width:220px;
background:var(--dd-bg-header);
border:1.5px solid var(--dd-accent);
border-radius:10px;
box-shadow:0 8px 28px rgba(0,0,0,.5);
overflow:hidden;z-index:30;
padding:4px;
}
.dd-download-menu.hidden{display:none}
.dd-menu-item{
display:flex;align-items:center;justify-content:space-between;
width:100%;box-sizing:border-box;
background:transparent;border:none;border-radius:7px;
padding:10px 12px;cursor:pointer;
color:var(--dd-text);font-size:13px;font-weight:600;
text-align:left;gap:10px;
transition:background .12s;
}
.dd-menu-item:hover{
background:rgba(59,130,246,.2);
}
.dd-menu-item.dd-menu-active{
background:rgba(59,130,246,.25);
box-shadow:inset 3px 0 0 var(--dd-accent);
}
.dd-menu-desc{
font-size:11px;font-weight:500;color:var(--dd-text-muted);
opacity:.85;
}
#xamz-grid{
flex:1;overflow:auto;padding:12px;
display:grid;
grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
gap:12px;align-content:start;
background:var(--dd-bg-grid);
}
#xamz-grid::-webkit-scrollbar{width:8px}
#xamz-grid::-webkit-scrollbar-track{background:transparent}
#xamz-grid::-webkit-scrollbar-thumb{
background:var(--dd-accent);border-radius:4px;
}
.dd-empty-msg{
grid-column:1/-1;text-align:center;padding:40px 20px;
color:var(--dd-text-muted);
display:flex;flex-direction:column;align-items:center;gap:8px;
}
.dd-empty-icon{font-size:40px;line-height:1;opacity:.85}
.dd-empty-title{font-size:15px;font-weight:700;color:var(--dd-text)}
.dd-empty-desc{font-size:12px;line-height:1.5;opacity:.85;margin-bottom:6px}
.dd-empty-btn{
background:var(--dd-accent);color:#000;border:none;border-radius:8px;
padding:9px 16px;font-weight:700;font-size:12px;cursor:pointer;
}
.dd-empty-btn:hover{background:var(--dd-accent-hover)}
#xamz-box.dd-thumb-s #xamz-grid{grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px}
#xamz-box.dd-thumb-s .xamz-item img{height:130px}
#xamz-box.dd-thumb-m #xamz-grid{grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
#xamz-box.dd-thumb-m .xamz-item img{height:220px}
#xamz-box.dd-thumb-l #xamz-grid{grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px}
#xamz-box.dd-thumb-l .xamz-item img{height:300px}
#xamz-box.dd-compact #xamz-grid{gap:7px;padding:8px}
#xamz-box.dd-compact .xamz-item{padding:4px;border-radius:7px}
#xamz-box.dd-compact .xamz-item label{margin-top:4px;font-size:11px}
#xamz-box.dd-compact .xamz-number{font-size:10px;padding:2px 5px;left:6px;top:6px}
#xamz-box.dd-compact .dd-drag-handle{right:5px;top:5px;font-size:12px;padding:1px 3px}
.xamz-item{
position:relative;
background:var(--dd-bg-item);
border:1px solid var(--dd-item-border);
border-radius:10px;padding:7px;
transition:transform .15s, box-shadow .15s, border-color .15s, opacity .15s;
cursor:grab;
}
.xamz-item:hover{
border-color:var(--dd-accent-hover);
box-shadow:0 5px 16px rgba(59,130,246,.25);
transform:translateY(-2px);
}
.xamz-item.dd-dragging{ opacity:0.45; cursor:grabbing; transform:scale(0.97); }
.xamz-item.dd-dragging-ghost{ opacity:0.3; }
.xamz-item.dd-drag-over{
border-color:var(--dd-accent);
box-shadow:0 0 0 3px var(--dd-accent);
transform:scale(1.03);
}
.xamz-item img{
width:100%;height:220px;object-fit:contain;display:block;
background:#000;border-radius:6px;cursor:pointer;
}
.xamz-item label{
display:flex;align-items:center;gap:6px;margin-top:6px;
font-size:11px;color:var(--dd-text-muted);cursor:pointer;user-select:none;
}
.dd-panel-label{flex:1}
.dd-ext-badge{
font-size:9px;font-weight:700;letter-spacing:.3px;
color:var(--dd-text-muted);background:rgba(59,130,246,.12);
padding:1px 5px;border-radius:4px;
text-transform:uppercase;opacity:.7;
}
.xamz-number{
position:absolute;left:10px;top:10px;z-index:2;
background:var(--dd-accent);color:#000;
padding:2px 7px;border-radius:5px;
font-size:11px;font-weight:800;
box-shadow:0 2px 6px rgba(0,0,0,.35);
transition: background .15s, color .15s;
}
.xamz-item:has(input:checked) .xamz-number{
background:#000;color:var(--dd-accent);
}
.dd-drag-handle{
position:absolute;right:8px;top:8px;z-index:2;
color:var(--dd-text-muted);font-size:14px;line-height:1;
opacity:0.5;cursor:grab;user-select:none;
background:rgba(0,0,0,.3);padding:2px 4px;border-radius:4px;
}
.xamz-item:hover .dd-drag-handle{opacity:1}
.xamz-item:has(input:checked){
border-color:var(--dd-accent);
box-shadow:0 0 0 2px rgba(59,130,246,.45), 0 4px 12px rgba(59,130,246,.2);
background:color-mix(in srgb, var(--dd-bg-item) 85%, var(--dd-accent) 15%);
}
.dd-resize-edge{
position:absolute;z-index:12;background:transparent;
}
.dd-resize-edge:hover{background:rgba(59,130,246,.25)}
.dd-resize-n{top:0;left:12px;right:12px;height:6px;cursor:ns-resize}
.dd-resize-s{bottom:0;left:12px;right:12px;height:6px;cursor:ns-resize}
.dd-resize-e{right:0;top:12px;bottom:12px;width:6px;cursor:ew-resize}
.dd-resize-w{left:0;top:12px;bottom:12px;width:6px;cursor:ew-resize}
.dd-resize-corner{
position:absolute;width:14px;height:14px;z-index:13;
background:transparent;
}
.dd-resize-nw{top:0;left:0;cursor:nwse-resize;border-radius:12px 0 0 0}
.dd-resize-ne{top:0;right:0;cursor:nesw-resize;border-radius:0 12px 0 0}
.dd-resize-sw{bottom:0;left:0;cursor:nesw-resize;border-radius:0 0 0 12px}
.dd-resize-se{
bottom:2px;right:2px;width:16px;height:16px;cursor:nwse-resize;
background:
linear-gradient(135deg, transparent 50%, rgba(59,130,246,.35) 50%) no-repeat,
linear-gradient(135deg, transparent 60%, rgba(59,130,246,.55) 60%) no-repeat,
linear-gradient(135deg, transparent 70%, rgba(59,130,246,.85) 70%) no-repeat;
background-size:100% 100%;opacity:.75;
}
.dd-resize-se:hover{opacity:1}
.dd-resize-corner:hover{background:rgba(59,130,246,.2)}
.dd-resize-se:hover{background:
linear-gradient(135deg, transparent 50%, rgba(59,130,246,.5) 50%) no-repeat,
linear-gradient(135deg, transparent 60%, rgba(59,130,246,.7) 60%) no-repeat,
linear-gradient(135deg, transparent 70%, rgba(59,130,246,1) 70%) no-repeat;
background-size:100% 100%;
}
#xamz-box.dd-toolbar-collapsed #xamz-collapsible{display:none}
.xamz-item.dd-filtered-out{display:none !important}
#dd-filter-btn.dd-filter-active{
background:var(--dd-accent) !important;
color:#000 !important;
box-shadow:0 0 0 1px rgba(59,130,246,.4);
}
.dd-dl-btn.dd-btn-disabled, .dd-dl-btn:disabled{
opacity:0.45 !important;cursor:not-allowed !important;
transform:none !important;pointer-events:none;
}
.dd-skeleton{
background:linear-gradient(90deg,
rgba(59,130,246,.08) 25%,
rgba(59,130,246,.18) 50%,
rgba(59,130,246,.08) 75%);
background-size:200% 100%;
animation:ddSkeleton 1.2s ease-in-out infinite;
border-radius:10px;min-height:180px;
}
@keyframes ddSkeleton {
0%{background-position:200% 0}
100%{background-position:-200% 0}
}
.dd-hover-preview{
position:fixed;z-index:2147483647;pointer-events:none;
border:2px solid var(--dd-accent);border-radius:10px;overflow:hidden;
box-shadow:0 8px 32px rgba(0,0,0,.55);background:#0a0a12;
max-width:400px;
}
.dd-hover-preview.hidden{display:none}
.dd-hover-preview img{display:block;max-width:380px;max-height:480px;object-fit:contain;margin:0 auto}
.dd-hover-meta{
padding:8px 10px;font-size:11px;line-height:1.45;
color:#e2e8f0;background:rgba(10,10,18,.95);
border-top:1px solid rgba(59,130,246,.3);
word-break:break-all;
}
.dd-hover-meta b{color:#93c5fd;font-weight:600}
.dd-panel-meta{
display:flex;flex-wrap:wrap;gap:4px 8px;
font-size:10px;color:var(--dd-text-muted);
padding:2px 2px 0;line-height:1.3;
opacity:.85;
}
.dd-panel-meta span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.dd-accent-picker{
position:absolute;top:48px;right:14px;z-index:20;
display:flex;gap:5px;padding:7px;
background:var(--dd-bg-header);border:1px solid var(--dd-border);
border-radius:9px;box-shadow:0 4px 16px rgba(0,0,0,.3);
}
.dd-accent-picker.hidden{display:none}
.dd-accent-picker button{
width:24px;height:24px;border-radius:50%;
border:2px solid transparent;cursor:pointer;padding:0;
transition:transform .15s, border-color .15s;
}
.dd-accent-picker button:hover{transform:scale(1.15);border-color:#fff}
/* ===== Settings / Help modal ===== */
.dd-modal-bg{
position:fixed;inset:0;z-index:2147483647;
background:rgba(0,0,0,.72);
display:flex;align-items:center;justify-content:center;
font-family:'Segoe UI',Arial,sans-serif;
}
.dd-modal{
background:var(--dd-bg-header,#0c0c14);color:var(--dd-text,#fff);
width:90%;max-width:400px;max-height:80vh;border-radius:12px;overflow:hidden;
border:2px solid #3b82f6;box-shadow:0 8px 32px rgba(0,0,0,.5);
}
.dd-modal-wide{max-width:480px;}
.dd-ch-toolbar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:10px;}
.dd-ch-btn{
padding:6px 10px;border-radius:7px;border:1px solid rgba(59,130,246,.35);
background:rgba(59,130,246,.12);color:#93c5fd;font-size:11px;font-weight:700;cursor:pointer;
}
.dd-ch-btn:hover{background:var(--dd-accent);color:#000;border-color:var(--dd-accent);}
.dd-ch-count{margin-left:auto;font-size:11px;opacity:.7;}
.dd-ch-list{
max-height:42vh;overflow:auto;border:1px solid rgba(59,130,246,.2);
border-radius:8px;padding:4px;
}
.dd-ch-row{
display:flex;align-items:center;gap:8px;padding:7px 8px;
border-radius:6px;cursor:pointer;font-size:12px;
}
.dd-ch-row:hover{background:rgba(59,130,246,.1);}
.dd-ch-row.dd-ch-current{background:rgba(59,130,246,.18);}
.dd-ch-num{
min-width:28px;text-align:center;font-size:10px;font-weight:700;
color:#93c5fd;opacity:.8;
}
.dd-ch-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.dd-ch-cb{width:15px;height:15px;accent-color:#3b82f6;}
.dd-log-list{
max-height:45vh;overflow:auto;border:1px solid rgba(59,130,246,.2);
border-radius:8px;padding:4px;font-family:ui-monospace,Consolas,monospace;font-size:11px;
}
.dd-log-row{
display:grid;grid-template-columns:64px 48px 1fr;gap:6px;
padding:5px 6px;border-radius:5px;align-items:start;
}
.dd-log-row:nth-child(odd){background:rgba(59,130,246,.06);}
.dd-log-t{opacity:.55;font-size:10px;}
.dd-log-lv{font-weight:700;font-size:10px;text-transform:uppercase;}
.dd-log-info .dd-log-lv{color:#93c5fd;}
.dd-log-warn .dd-log-lv{color:#fbbf24;}
.dd-log-err .dd-log-lv{color:#f87171;}
.dd-log-msg{word-break:break-word;line-height:1.35;}
.dd-log-detail{opacity:.65;font-size:10px;}
.dd-modal-head{
padding:12px 16px;display:flex;align-items:center;justify-content:space-between;
background:rgba(59,130,246,.12);border-bottom:1px solid rgba(59,130,246,.3);
font-size:14px;
}
.dd-modal-head button{
background:#333;color:#fff;border:none;border-radius:6px;
padding:4px 10px;cursor:pointer;font-weight:700;
}
.dd-modal-body{padding:14px 16px;overflow:auto;max-height:60vh;}
.dd-help-row{
display:flex;align-items:center;gap:12px;
padding:8px 0;border-bottom:1px solid rgba(59,130,246,.15);font-size:13px;
}
.dd-help-row kbd{
min-width:72px;text-align:center;
background:rgba(59,130,246,.2);border:1px solid rgba(59,130,246,.4);
border-radius:6px;padding:4px 8px;font-size:12px;font-weight:700;
color:#93c5fd;
}
.dd-help-note{margin-top:12px;font-size:11px;opacity:.7;line-height:1.45;}
.dd-set-label{
display:flex;flex-direction:column;gap:4px;
margin-bottom:12px;font-size:12px;font-weight:600;color:#93c5fd;
}
.dd-set-label select{
padding:8px 10px;border-radius:8px;border:1px solid rgba(59,130,246,.4);
background:#0a0a12;color:#fff;font-size:13px;
}
.dd-set-check{
display:flex;align-items:center;gap:8px;
margin-bottom:10px;font-size:13px;cursor:pointer;
}
.dd-set-check input{width:16px;height:16px;accent-color:#3b82f6;}
.dd-set-actions{display:flex;gap:8px;margin-top:14px;}
.dd-set-save,.dd-set-reset{
flex:1;padding:10px;border:none;border-radius:8px;
font-weight:700;font-size:13px;cursor:pointer;
}
.dd-set-save{background:#3b82f6;color:#000;}
.dd-set-reset{background:#333;color:#fff;}
#xamz-box button:focus-visible,
#xamz-box input:focus-visible{
outline:2px solid var(--dd-accent);outline-offset:2px;
}
.xamz-item:focus-within{
border-color:var(--dd-accent);
box-shadow:0 0 0 2px rgba(59,130,246,.35);
}
#xamz-box{border-color:var(--dd-accent)}
.xamz-number, .dd-empty-btn{
background:var(--dd-accent);
}
`;
document.head.appendChild(style);
}
function showSkeleton() {
const grid = document.getElementById('xamz-grid');
if (!grid) return;
const count = Math.min(8, Math.max(4, imageURLs.length || 6));
grid.innerHTML = Array.from({ length: count }, () =>
`<div class="dd-skeleton"></div>`
).join('');
}
function updatePanel() {
const grid = document.getElementById('xamz-grid');
if (!grid) return;
// Simpan state checkbox sebelum rebuild
const prevChecked = new Set();
grid.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (cb.checked) prevChecked.add(+cb.dataset.index);
});
const hadItems = grid.querySelectorAll('.xamz-item').length > 0;
imageURLs = imageURLs.filter(u => isTargetImage(u));
if (isSortByFilenameSite() && imageURLs.length > 0) {
imageURLs = sortUrlsByFilename(dedupeByBasename(imageURLs));
}
const countEl = document.getElementById('xamz-count');
if (countEl) countEl.textContent = imageURLs.length + ' ' + t('images');
if (!imageURLs.length) {
grid.innerHTML = `
<div class="dd-empty-msg">
<div class="dd-empty-icon">📖</div>
<div class="dd-empty-title">${t('emptyTitle')}</div>
<div class="dd-empty-desc">${t('emptyDesc')}</div>
<button id="dd-empty-scroll" class="dd-empty-btn">${t('emptyScroll')}</button>
</div>`;
const emptyBtn = document.getElementById('dd-empty-scroll');
if (emptyBtn) emptyBtn.onclick = autoScrollScan;
updateSelectedUI();
return;
}
// Default: semua tercentang kalau pertama kali; kalau rebuild, jaga state lama
const defaultChecked = !hadItems || prevChecked.size === 0;
const frag = document.createDocumentFragment();
imageURLs.forEach((url, index) => {
const item = document.createElement('div');
item.className = 'xamz-item';
item.draggable = true;
item.dataset.index = index;
const ext = getExtFromUrl(url).toUpperCase();
const sizeBytes = estimatedSizes[url] || estimatedSizes[normalizeUrl(url)] || 0;
const sizeStr = sizeBytes ? formatBytes(sizeBytes) : '…';
const isChecked = defaultChecked || prevChecked.has(index);
item.innerHTML = `
<div class="xamz-number">${String(index + 1).padStart(3, '0')}</div>
<div class="dd-drag-handle" title="Drag untuk ubah urutan">⠿</div>
<img src="${url}" loading="lazy" decoding="async" data-index="${index}" draggable="false">
<label>
<input type="checkbox" ${isChecked ? 'checked' : ''} data-index="${index}">
<span class="dd-panel-label">${t('panel')} ${index + 1}</span>
<span class="dd-ext-badge">${ext}</span>
</label>
<div class="dd-panel-meta" data-meta="${index}">
<span class="dd-meta-res">—</span>
<span class="dd-meta-size">${sizeStr}</span>
</div>
`;
const img = item.querySelector('img');
attachThumbFallback(img, url);
const checkbox = item.querySelector('input[type="checkbox"]');
const resEl = item.querySelector('.dd-meta-res');
// Tidak ada HEAD request per-gambar (berat). Ukuran dari transferSize / saat download.
img.addEventListener('load', () => {
const w = img.naturalWidth;
const h = img.naturalHeight;
if (resEl && w && h) resEl.textContent = `${w}×${h}`;
item.dataset.w = w;
item.dataset.h = h;
}, { once: true });
img.addEventListener('click', (e) => {
e.stopPropagation();
if (e.shiftKey && lastSelectedIndex >= 0) {
const from = Math.min(lastSelectedIndex, index);
const to = Math.max(lastSelectedIndex, index);
document.querySelectorAll('#xamz-grid input[type="checkbox"]').forEach(cb => {
const i = +cb.dataset.index;
if (i >= from && i <= to) cb.checked = true;
});
} else {
checkbox.checked = !checkbox.checked;
lastSelectedIndex = index;
}
updateSelectedUI();
});
checkbox.addEventListener('click', (e) => {
if (e.shiftKey && lastSelectedIndex >= 0) {
e.preventDefault();
const from = Math.min(lastSelectedIndex, index);
const to = Math.max(lastSelectedIndex, index);
const targetState = !checkbox.checked;
document.querySelectorAll('#xamz-grid input[type="checkbox"]').forEach(cb => {
const i = +cb.dataset.index;
if (i >= from && i <= to) cb.checked = targetState;
});
}
lastSelectedIndex = index;
updateSelectedUI();
if (showSelectedOnly) applySelectedOnlyFilter();
});
img.addEventListener('mouseenter', () => {
clearTimeout(hoverPreviewTimer);
hoverPreviewTimer = setTimeout(() => {
const preview = document.getElementById('dd-hover-preview');
const pImg = document.getElementById('dd-hover-img');
const meta = document.getElementById('dd-hover-meta');
if (!preview || !pImg) return;
pImg.src = img.src || url;
const w = +item.dataset.w || img.naturalWidth || 0;
const h = +item.dataset.h || img.naturalHeight || 0;
const bytes = estimatedSizes[url] || sizeBytes || 0;
if (meta) {
const parts = [];
if (w && h) parts.push(`<b>${w}×${h}</b>`);
if (bytes) parts.push(`<b>${formatBytes(bytes)}</b>`);
meta.innerHTML = parts.join(' · ') || '—';
}
preview.classList.remove('hidden');
const rect = img.getBoundingClientRect();
let left = rect.right + 12;
let top = rect.top;
if (left + 400 > window.innerWidth) left = rect.left - 412;
if (top + 520 > window.innerHeight) top = window.innerHeight - 540;
if (top < 8) top = 8;
if (left < 8) left = 8;
preview.style.left = left + 'px';
preview.style.top = top + 'px';
}, 400);
});
img.addEventListener('mouseleave', () => {
clearTimeout(hoverPreviewTimer);
document.getElementById('dd-hover-preview')?.classList.add('hidden');
});
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index);
item.classList.add('dd-dragging');
setTimeout(() => item.classList.add('dd-dragging-ghost'), 0);
});
item.addEventListener('dragend', () => {
item.classList.remove('dd-dragging', 'dd-dragging-ghost');
grid.querySelectorAll('.xamz-item').forEach(el => el.classList.remove('dd-drag-over'));
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!e.currentTarget.classList.contains('dd-dragging')) {
e.currentTarget.classList.add('dd-drag-over');
}
});
item.addEventListener('dragleave', (e) => {
e.currentTarget.classList.remove('dd-drag-over');
});
item.addEventListener('drop', (e) => {
e.preventDefault();
e.currentTarget.classList.remove('dd-drag-over');
const fromIdx = parseInt(e.dataTransfer.getData('text/plain'), 10);
const toIdx = parseInt(e.currentTarget.dataset.index, 10);
if (isNaN(fromIdx) || isNaN(toIdx) || fromIdx === toIdx) return;
const [moved] = imageURLs.splice(fromIdx, 1);
imageURLs.splice(toIdx, 0, moved);
updatePanel();
});
frag.appendChild(item);
});
grid.innerHTML = '';
grid.appendChild(frag);
updateSelectedUI();
}
function getSelectedIndexes() {
return [...document.querySelectorAll('#xamz-grid input:checked')].map(cb => +cb.dataset.index);
}
// ==================== Auto Scroll ====================
async function autoScrollScan() {
showMiniProgress();
setMiniProgress(0, 'Scrolling...');
const step = Math.max(400, Math.floor(window.innerHeight * 0.75));
let current = 0;
let stableRounds = 0; // berapa kali tinggi halaman tidak bertambah
let lastHeight = 0;
let lastCount = imageURLs.length;
let iterations = 0;
const MAX_ITER = 120; // safety: jangan infinite loop
const NEED_STABLE = 3; // stop setelah 3x tinggi + count stabil di bawah
// Mulai dari atas sekali
window.scrollTo(0, 0);
await new Promise(r => setTimeout(r, 200));
while (iterations < MAX_ITER) {
iterations++;
const totalHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
window.scrollTo(0, current);
await new Promise(r => setTimeout(r, 350));
scanXAmz();
const newHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
);
const newCount = imageURLs.length;
// Progress berdasarkan posisi scroll (tinggi dinamis)
const pct = Math.min(98, Math.round((current / Math.max(newHeight, 1)) * 100));
setMiniProgress(pct, `Scrolling... ${pct}% • ${newCount} gambar`);
// Deteksi stabil: tinggi & jumlah gambar tidak berubah
if (newHeight <= lastHeight + 20 && newCount === lastCount && current + step >= newHeight) {
stableRounds++;
} else {
stableRounds = 0;
}
lastHeight = newHeight;
lastCount = newCount;
if (stableRounds >= NEED_STABLE) break;
current += step;
// Kalau sudah melewati ujung, tarik ke bawah sekali lagi lalu cek stabil
if (current >= newHeight) {
current = newHeight;
window.scrollTo(0, newHeight);
await new Promise(r => setTimeout(r, 400));
scanXAmz();
// jangan reset ke 0 — itu yang bikin loop tanpa henti
// cukup tunggu stabil di bawah
if (imageURLs.length === lastCount &&
Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) <= lastHeight + 20) {
stableRounds++;
} else {
lastHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
lastCount = imageURLs.length;
stableRounds = 0;
// konten baru muncul: lanjut sedikit dari posisi sekarang (bukan dari 0)
current = Math.max(0, lastHeight - step * 2);
}
if (stableRounds >= NEED_STABLE) break;
}
}
window.scrollTo(0, 0);
scanXAmz();
updatePanel();
setMiniProgress(100, `✅ Scroll selesai • ${imageURLs.length} gambar`);
setTimeout(() => {
hideMiniProgress();
updateSelectedUI();
}, 2000);
}
// ==================== Concurrent Helper ====================
async function runConcurrent(items, concurrency, workerFn, onProgress) {
let completed = 0;
let nextIndex = 0;
const results = new Array(items.length);
async function worker() {
while (nextIndex < items.length) {
const i = nextIndex++;
try {
results[i] = await workerFn(items[i], i);
} catch (e) {
results[i] = null;
}
completed++;
if (onProgress) onProgress(completed, items.length, results[i], i);
}
}
const workers = [];
const n = Math.min(concurrency, items.length);
for (let c = 0; c < n; c++) workers.push(worker());
await Promise.all(workers);
return results;
}
// ==================== Download ====================
function downloadSelected() {
const indexes = getSelectedIndexes();
if (!indexes.length) return alert('Pilih dulu panelnya');
startDownloads(indexes);
}
async function startDownloads(indexes) {
if (isDownloading) return;
// Folder mode: pilih folder dulu (masih dalam user gesture)
if (saveMode === 'folder' && !(await ensureSaveFolder())) {
return;
}
beginDownload();
let ok = 0, fail = 0;
setMiniProgress(0, `0/${indexes.length}`, { done: 0, total: indexes.length, bytes: 0 });
await runConcurrent(indexes, dlConcurrency, async (idx) => {
const url = imageURLs[idx];
const result = await downloadOne(idx);
if (result && estimatedSizes[url]) {
downloadBytesDone += estimatedSizes[url];
} else if (result) {
downloadBytesDone += avgImageSize;
}
return result;
}, (done, total, success) => {
if (success) ok++; else fail++;
const pct = (done / total) * 100;
setMiniProgress(pct, `${done}/${total}`, { done, total, bytes: downloadBytesDone });
});
setMiniProgress(100, `✅ ${ok} berhasil, ${fail} gagal`);
addHistory(getTitle(), 'Single', ok);
notify('Pawchive DL', `Download selesai: ${ok} berhasil, ${fail} gagal`);
endDownload();
setTimeout(() => hideMiniProgress(), 4000);
}
async function downloadAsZip() {
const indexes = getSelectedIndexes();
if (!indexes.length) return alert('Pilih dulu panelnya');
if (isDownloading) return;
let zipName = (document.getElementById('xamz-zipname')?.value || '').trim()
.replace(/[\\/:*?"<>|]/g, '');
if (!zipName) zipName = 'Pawchive';
if (!zipName.toLowerCase().endsWith('.zip')) zipName += '.zip';
// Pilih lokasi SEKARANG (sebelum download async) — penting untuk Save As
if (!(await prepareSaveTarget(zipName))) return;
beginDownload();
const files = new Array(indexes.length);
let ok = 0, fail = 0;
setMiniProgress(0, `Gambar 0/${indexes.length}`, { done: 0, total: indexes.length, bytes: 0 });
await runConcurrent(indexes, Math.max(2, dlConcurrency + 1), async (idx, i) => {
try {
const blob = await fetchImageAsBlob(imageURLs[idx]);
if (blob) {
estimatedSizes[imageURLs[idx]] = blob.size;
downloadBytesDone += blob.size;
const ext = getExtFromUrl(imageURLs[idx]);
const name = String(i + 1).padStart(3, '0') + '.' + ext;
files[i] = { name, data: new Uint8Array(await blob.arrayBuffer()) };
return true;
}
} catch (e) {}
return false;
}, (done, total, success) => {
if (success) ok++; else fail++;
const pct = (done / total) * 90;
setMiniProgress(pct, `Gambar ${done}/${total}`, { done, total, bytes: downloadBytesDone });
});
const validFiles = files.filter(Boolean);
if (validFiles.length === 0) {
setMiniProgress(0, '❌ Gagal ambil gambar');
endDownload();
setTimeout(() => hideMiniProgress(), 3000);
return;
}
setMiniProgress(95, 'Membuat ZIP...');
try {
const zipBlob = createZip(validFiles);
const saved = await saveBlob(zipBlob, zipName);
if (!saved) {
setMiniProgress(0, '❌ Simpan dibatalkan');
} else {
setMiniProgress(100, `✅ ZIP siap! ${ok} gambar • ${formatBytes(zipBlob.size)}`);
addHistory(zipName.replace(/\.zip$/i, ''), 'ZIP', ok);
notify('Pawchive DL', `ZIP berhasil: ${ok} gambar (${formatBytes(zipBlob.size)})`);
}
} catch (e) {
setMiniProgress(0, '❌ Gagal buat ZIP');
}
endDownload();
setTimeout(() => hideMiniProgress(), 4000);
}
async function downloadAsPdf() {
const indexes = getSelectedIndexes();
if (!indexes.length) return alert('Pilih dulu panelnya');
if (isDownloading) return;
let pdfName = (document.getElementById('xamz-zipname')?.value || '').trim()
.replace(/[\\/:*?"<>|]/g, '');
if (!pdfName) pdfName = 'Pawchive';
if (!pdfName.toLowerCase().endsWith('.pdf')) pdfName += '.pdf';
if (!(await prepareSaveTarget(pdfName))) return;
beginDownload();
const pages = new Array(indexes.length);
let ok = 0, fail = 0;
setMiniProgress(0, `Convert 0/${indexes.length}`, { done: 0, total: indexes.length, bytes: 0 });
await runConcurrent(indexes, Math.min(3, Math.max(1, dlConcurrency - 1)), async (idx, i) => {
try {
const jpgBlob = await convertToJpg(imageURLs[idx]);
if (jpgBlob) {
downloadBytesDone += jpgBlob.size;
pages[i] = jpgBlob;
return true;
}
} catch (e) {}
return false;
}, (done, total, success) => {
if (success) ok++; else fail++;
const pct = (done / total) * 80;
setMiniProgress(pct, `Convert ${done}/${total}`, { done, total, bytes: downloadBytesDone });
});
const validPages = pages.filter(Boolean);
if (validPages.length === 0) {
setMiniProgress(0, '❌ Gagal convert');
endDownload();
setTimeout(() => hideMiniProgress(), 3000);
return;
}
setMiniProgress(90, 'Membuat PDF...');
try {
const pageData = [];
for (const b of validPages) {
const buf = new Uint8Array(await b.arrayBuffer());
const dims = getJpegSize(buf);
pageData.push({ data: buf, width: dims.w, height: dims.h });
}
const pdfBlob = buildPdfBinary(pageData, pdfName.replace(/\.pdf$/i, ''));
const saved = await saveBlob(pdfBlob, pdfName);
if (!saved) {
setMiniProgress(0, '❌ Simpan dibatalkan');
} else {
setMiniProgress(100, `✅ PDF siap! ${ok} halaman • ${formatBytes(pdfBlob.size)}`);
addHistory(pdfName.replace(/\.pdf$/i, ''), 'PDF', ok);
notify('Pawchive DL', `PDF berhasil: ${ok} halaman (${formatBytes(pdfBlob.size)})`);
}
} catch (e) {
console.error(e);
setMiniProgress(0, '❌ Gagal buat PDF');
}
endDownload();
setTimeout(() => hideMiniProgress(), 4000);
}
async function downloadAsPdfStrip() {
const indexes = getSelectedIndexes();
if (!indexes.length) return alert('Pilih dulu panelnya');
if (isDownloading) return;
let pdfName = (document.getElementById('xamz-zipname')?.value || '').trim()
.replace(/[\\/:*?"<>|]/g, '');
if (!pdfName) pdfName = 'Pawchive';
if (!pdfName.toLowerCase().endsWith('.pdf')) pdfName += '.pdf';
if (!/_strip$/i.test(pdfName.replace(/\.pdf$/i, ''))) {
pdfName = pdfName.replace(/\.pdf$/i, '') + '_strip.pdf';
}
if (!(await prepareSaveTarget(pdfName))) return;
beginDownload();
const bitmaps = new Array(indexes.length);
let ok = 0, fail = 0;
setMiniProgress(0, `Load 0/${indexes.length}`, { done: 0, total: indexes.length, bytes: 0 });
await runConcurrent(indexes, dlConcurrency, async (idx, i) => {
try {
const blob = await fetchImageAsBlob(imageURLs[idx]);
if (!blob) return false;
estimatedSizes[imageURLs[idx]] = blob.size;
downloadBytesDone += blob.size;
const bmp = await createImageBitmap(blob);
bitmaps[i] = bmp;
return true;
} catch (e) {
return false;
}
}, (done, total, success) => {
if (success) ok++; else fail++;
const pct = (done / total) * 70;
setMiniProgress(pct, `Load ${done}/${total}`, { done, total, bytes: downloadBytesDone });
});
const validBitmaps = bitmaps.filter(Boolean);
if (validBitmaps.length === 0) {
setMiniProgress(0, '❌ Gagal load gambar');
endDownload();
setTimeout(() => hideMiniProgress(), 3000);
return;
}
setMiniProgress(75, 'Menyusun strip...');
try {
const maxW = Math.max(...validBitmaps.map(b => b.width));
let totalH = 0;
const scaled = validBitmaps.map(b => {
const scale = maxW / b.width;
const h = Math.round(b.height * scale);
totalH += h;
return { bmp: b, w: maxW, h };
});
const MAX_CANVAS = 16384;
const pageData = [];
async function canvasToJpegPage(items, height) {
const canvas = document.createElement('canvas');
canvas.width = maxW;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, maxW, height);
let y = 0;
for (const s of items) {
ctx.drawImage(s.bmp, 0, y, s.w, s.h);
y += s.h;
}
const jpgBlob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', jpegQuality));
const buf = new Uint8Array(await jpgBlob.arrayBuffer());
return { data: buf, width: maxW, height: height };
}
if (totalH > MAX_CANVAS) {
const chunks = [];
let cur = [], curH = 0;
for (const s of scaled) {
if (curH + s.h > MAX_CANVAS && cur.length) {
chunks.push({ items: cur, h: curH });
cur = [];
curH = 0;
}
cur.push(s);
curH += s.h;
}
if (cur.length) chunks.push({ items: cur, h: curH });
for (let ci = 0; ci < chunks.length; ci++) {
setMiniProgress(75 + (ci / chunks.length) * 15, `Strip part ${ci + 1}/${chunks.length}`);
pageData.push(await canvasToJpegPage(chunks[ci].items, chunks[ci].h));
}
} else {
setMiniProgress(85, 'Render strip...');
pageData.push(await canvasToJpegPage(scaled, totalH));
}
setMiniProgress(95, 'Membuat PDF strip...');
const pdfBlob = buildPdfBinary(pageData, pdfName.replace(/\.pdf$/i, ''));
const saved = await saveBlob(pdfBlob, pdfName);
validBitmaps.forEach(b => { try { b.close(); } catch (e) {} });
if (!saved) {
setMiniProgress(0, '❌ Simpan dibatalkan');
} else {
setMiniProgress(100, `✅ Strip PDF siap! ${ok} gambar • ${formatBytes(pdfBlob.size)}`);
addHistory(pdfName.replace(/\.pdf$/i, ''), 'PDF Strip', ok);
notify('Pawchive DL', `PDF Strip berhasil: ${ok} gambar (${formatBytes(pdfBlob.size)})`);
}
} catch (e) {
console.error(e);
setMiniProgress(0, '❌ Gagal buat strip PDF');
}
endDownload();
setTimeout(() => hideMiniProgress(), 4000);
}
function convertToJpg(url) {
return new Promise(async (resolve) => {
try {
const blob = await fetchImageAsBlob(url);
if (!blob) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
const objectUrl = URL.createObjectURL(blob);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
canvas.toBlob((b) => {
URL.revokeObjectURL(objectUrl);
resolve(b);
}, 'image/jpeg', jpegQuality);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
resolve(null);
};
img.src = objectUrl;
} catch (e) {
resolve(null);
}
});
}
function getJpegSize(buf) {
let i = 2;
while (i < buf.length - 8) {
if (buf[i] !== 0xFF) break;
const marker = buf[i + 1];
if (marker === 0xC0 || marker === 0xC1 || marker === 0xC2) {
const h = (buf[i + 5] << 8) | buf[i + 6];
const w = (buf[i + 7] << 8) | buf[i + 8];
return { w, h };
}
const len = (buf[i + 2] << 8) | buf[i + 3];
i += 2 + len;
}
return { w: 800, h: 1200 };
}
function buildPdfBinary(pages, title) {
const encoder = new TextEncoder();
const parts = [];
const offsets = [0];
let pos = 0;
const pushStr = (str) => {
const data = encoder.encode(str);
parts.push(data);
pos += data.length;
};
const pushBin = (data) => {
parts.push(data);
pos += data.length;
};
pushStr('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
offsets.push(pos);
pushStr('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n');
offsets.push(pos);
let kids = '';
for (let i = 0; i < pages.length; i++) kids += (3 + i * 3) + ' 0 R ';
pushStr(`2 0 obj\n<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>\nendobj\n`);
let objNum = 3;
for (let i = 0; i < pages.length; i++) {
const p = pages[i];
const pageObj = objNum;
const contentObj = objNum + 1;
const imageObj = objNum + 2;
offsets.push(pos);
pushStr(`${pageObj} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.width} ${p.height}] /Contents ${contentObj} 0 R /Resources << /XObject << /Im${i} ${imageObj} 0 R >> /ProcSet [/PDF /ImageC] >> >>\nendobj\n`);
const stream = `q\n${p.width} 0 0 ${p.height} 0 0 cm\n/Im${i} Do\nQ\n`;
offsets.push(pos);
pushStr(`${contentObj} 0 obj\n<< /Length ${stream.length} >>\nstream\n${stream}endstream\nendobj\n`);
offsets.push(pos);
pushStr(`${imageObj} 0 obj\n<< /Type /XObject /Subtype /Image /Width ${p.width} /Height ${p.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${p.data.length} >>\nstream\n`);
pushBin(p.data);
pushStr('\nendstream\nendobj\n');
objNum += 3;
}
const infoObj = objNum;
offsets.push(pos);
const safeTitle = (title || 'Pawchive').replace(/[()\\]/g, '');
pushStr(`${infoObj} 0 obj\n<< /Title (${safeTitle}) /Producer (Pawchive Downloader) /Creator (qxyraxp) >>\nendobj\n`);
objNum++;
const xrefPos = pos;
let xref = `xref\n0 ${objNum}\n0000000000 65535 f \n`;
for (let i = 1; i < offsets.length; i++) {
xref += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
}
pushStr(xref);
pushStr(`trailer\n<< /Size ${objNum} /Root 1 0 R /Info ${infoObj} 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`);
const totalLen = parts.reduce((s, p) => s + p.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const p of parts) {
result.set(p, offset);
offset += p.length;
}
return new Blob([result], { type: 'application/pdf' });
}
// ==================== Helper ====================
function getExtFromUrl(url) {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (pathname.endsWith('.webp')) return 'webp';
if (pathname.endsWith('.avif')) return 'avif';
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'jpg';
if (pathname.endsWith('.png')) return 'png';
const u = url.toLowerCase();
if (u.includes('.webp')) return 'webp';
if (u.includes('.avif')) return 'avif';
if (u.includes('.png')) return 'png';
} catch (e) {}
return 'jpg';
}
// Fallback thumbnail panel: kalau gambar original diblok (mis. ERR_BLOCKED_BY_CLIENT
// oleh AdBlock) atau gagal load, coba via GM_xmlhttpRequest -> blob, lalu versi thumbnail.
function attachThumbFallback(img, url) {
let step = 0;
const markFailed = () => {
img.style.opacity = '0.35';
img.title = 'Gagal load preview (download tetap dicoba lewat GM_xmlhttpRequest)';
ddLog('warn', 'Preview gagal', url);
};
img.addEventListener('error', () => {
step++;
if (step === 1) {
try {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
headers: { 'Referer': location.href },
onload: (res) => {
if (res.status >= 200 && res.status < 300 && res.response) {
img.src = URL.createObjectURL(res.response);
} else {
img.dispatchEvent(new Event('error'));
}
},
onerror: () => img.dispatchEvent(new Event('error')),
ontimeout: () => img.dispatchEvent(new Event('error'))
});
return;
} catch (e) { step++; }
}
if (step === 2) {
const thumb = url.replace(/\/\/file\.pawchive\.pw\/data\//i, '//img.pawchive.pw/thumbnail/data/');
if (thumb !== url) { img.src = thumb; return; }
step++;
}
if (step >= 3) markFailed();
});
}
function fetchImageAsBlob(url) {
return new Promise((resolve) => {
const img = [...document.querySelectorAll('#xamz-grid img')].find(i => i.src === url);
if (img && img.complete && img.naturalWidth > 0) {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
const ext = getExtFromUrl(url);
let mime = 'image/jpeg';
if (ext === 'webp') mime = 'image/webp';
else if (ext === 'png') mime = 'image/png';
canvas.toBlob(b => {
if (b) estimatedSizes[url] = b.size;
resolve(b);
}, mime, 0.92);
return;
} catch (e) {}
}
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
headers: {
'Referer': location.href,
'Origin': location.origin,
'Accept': 'image/avif,image/webp,image/jpeg,image/png,image/*,*/*;q=0.8'
},
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
let mime = 'image/jpeg';
const ct = (res.responseHeaders || '').toLowerCase();
if (ct.includes('image/webp')) mime = 'image/webp';
else if (ct.includes('image/avif')) mime = 'image/avif';
else if (ct.includes('image/png')) mime = 'image/png';
else {
const ul = url.toLowerCase();
if (ul.includes('.webp')) mime = 'image/webp';
else if (ul.includes('.avif')) mime = 'image/avif';
else if (ul.includes('.png')) mime = 'image/png';
}
const blob = new Blob([res.response], { type: mime });
estimatedSizes[url] = blob.size;
resolve(blob);
} else resolve(null);
},
onerror: () => resolve(null),
ontimeout: () => resolve(null)
});
});
}
async function downloadOne(index) {
const url = imageURLs[index];
const ext = getExtFromUrl(url);
const filename = String(index + 1).padStart(3, '0') + '.' + ext;
try {
const blob = await fetchImageAsBlob(url);
if (blob) {
return await saveBlob(blob, filename);
}
} catch (e) {}
try {
await new Promise((resolve, reject) => {
GM_download({
url,
name: filename,
saveAs: saveMode === 'ask',
onload: resolve,
onerror: reject
});
});
return true;
} catch (e) {}
return false;
}
function isReaderPage() {
if (/\/reader\//i.test(location.pathname)) return true;
if (document.querySelectorAll('img[src*="desu.pics"], img[src*="amz-ch"]').length > 2) return true;
// MangaToon / Themesia / Kanzenin
if (document.querySelector('#readerarea img, .readercontent img, img.ts-main-image')) return true;
if (/mangatoon\.cc|kanzenin\.(info|xyz)|komikdewasa\.art/i.test(location.hostname) &&
/chapter/i.test(location.pathname + document.title)) return true;
// pawchive: HANYA halaman post (.../post/123), bukan list user /fanbox/user/...
if (/pawchive\.pw/i.test(location.hostname) && /\/post\/\d+/i.test(location.pathname)) return true;
return false;
}
function init() {
if (!document.body) return setTimeout(init, 300);
createButton();
ddLog('info', 'Script aktif', location.hostname + location.pathname);
// Restore folder handle dari IndexedDB (permission dicek lagi saat download)
if (saveMode === 'folder') restoreDirHandle();
setTimeout(scanXAmz, 1500);
setTimeout(scanXAmz, 4000);
setTimeout(showQueueBanner, 1200);
if (autoOpenPanel && isReaderPage()) {
setTimeout(() => {
if (!panelOpen) openPanel();
}, 1800);
}
}
init();
})();