// ==UserScript==
// @name Hitomi Tweak
// @namespace hitomi-tweaks
// @version 3.72
// @description Poster grid, dark mode, dan reader scroll vertikal ala webtoon untuk hitomi.la (anti-kedip) + panel Filter Tag
// @description:en Poster grid, dark mode, vertical webtoon-style reader and Tag Filter panel for hitomi.la (flicker-free)
// @author qxyraxp
// @license MIT
// @homepageURL https://github.com/qxyraxp/hitomi-tweaks
// @supportURL https://github.com/qxyraxp/hitomi-tweaks/issues
// @downloadURL https://raw.githubusercontent.com/qxyraxp/hitomi-tweaks/main/hitomi-tweaks.user.js
// @updateURL https://raw.githubusercontent.com/qxyraxp/hitomi-tweaks/main/hitomi-tweaks.user.js
// @match https://hitomi.la/*
// @run-at document-start
// @grant none
// ==/UserScript==
(() => {
'use strict';
if (window.top !== window.self) return;
if (document.getElementById('hmt-style')) return;
/* ====================================================================
* Helper dasar
* ==================================================================== */
const txt = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : '');
const mk = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
const onReady = (fn) => {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
else fn();
};
/* ====================================================================
* Settings (localStorage)
* ==================================================================== */
const KEY = 'hmt:';
const get = (k, d) => {
try { const v = localStorage.getItem(KEY + k); return v === null ? d : JSON.parse(v); }
catch { return d; }
};
const set = (k, v) => { try { localStorage.setItem(KEY + k, JSON.stringify(v)); } catch {} };
const state = {
lang: get('lang', 'id'), // bahasa antarmuka: 'id' | 'en'
dark: get('dark', true),
grid: get('grid', true),
webtoon: get('webtoon', true),
cardW: get('cardW', 190),
cardStyle: get('cardStyle', 'detail'), // 'detail' (cover + tabel info) | 'poster'
readerW: get('readerW', 800),
thumbs: get('thumbs', true), // thumbnail besar di halaman galeri
thumbReader: get('thumbReader', false), // thumbnail pakai gambar reader (berat); default: thumbnail situs (ringan)
thumbW: get('thumbW', 160),
pvOn: get('pvOn', true), // hover preview
pvSize: get('pvSize', 80), // tinggi preview, % tinggi layar
pvDelay: get('pvDelay', 150), // ms
pvMode: get('pvMode', 'side'), // 'side' | 'cursor'
dlPanel: get('dlPanel', true), // klik Download -> panel pilih gambar dulu
dlFmt: get('dlFmt', 'orig'), // format hasil unduhan: 'orig' | 'jpg'
dlQ: get('dlQ', 90), // kualitas JPG (%)
dlOut: get('dlOut', 'zip'), // hasil: 'zip' | 'pdf' | 'img' (gambar lepas, perlu folder)
dlDest: get('dlDest', 'dl'), // tujuan: 'dl' (unduhan browser) | 'dir' (folder pilihan)
toastPos: get('toastPos', 'br'), // posisi notifikasi: br bl bc tr tl tc
introScroll: get('introScroll', true), // animasi kartu/thumbnail saat di-scroll
introScrollStyle: get('introScrollStyle', 'same'), // 'same' = ikut gaya kartu, atau kunci INTRO_FX / 'mix'
introBlock: get('introBlock', 'rise'), // animasi cover + info di halaman galeri -> INTRO_FX
introNav: get('introNav', 'slide'), // animasi navbar (menu atas) -> HEAD_FX
introBanner: get('introBanner', 'wipe'), // animasi banner judul -> HEAD_FX
introOn: get('introOn', true), // animasi kartu saat halaman dimuat
introStyle: get('introStyle', 'rise'), // gaya animasi (lihat INTRO_FX)
introOrder: get('introOrder', 'seq'), // urutan muncul: 'seq' | 'diag' | 'rand'
introSpeed: get('introSpeed', 1), // pengali durasi animasi
introStep: get('introStep', 80), // jeda antar kartu (ms)
stickyNav: get('stickyNav', true), // header/navbar tetap terlihat saat scroll
searchOn: get('searchOn', true), // perpanjang kolom search
searchW: get('searchW', 520), // lebar kolom search (px)
accent: get('accent', '#5b7cff'), // warna aksen
dlMark: get('dlMark', true), // tandai galeri yang sudah diunduh
dlThumb: get('dlThumb', 150), // lebar minimum thumbnail di panel pilih unduhan (px)
romaji: get('romaji', true), // ubah tulisan Jepang di judul jadi romaji
romajiKanji: get('romajiKanji', true), // pakai kamus (kuromoji) supaya kanji ikut diubah
romajiLong: get('romajiLong', 'simple'), // vokal panjang: 'simple' (ou->o) | 'keep'
tfInc: get('tfInc', []), // filter tag: tag yang disertakan (query pencarian)
tfExc: get('tfExc', []), // filter tag: tag yang dikecualikan (diawali "-")
};
/* ====================================================================
* Versi + bahasa (Indonesia / English)
* Teks sumber di script ini berbahasa Indonesia; T() menerjemahkannya ke English
* lewat kamus EN di bawah. {0}, {1} = nilai yang disisipkan.
* ==================================================================== */
const VERSION = '3.72';
const AUTHOR = 'qxyraxp';
const EN = {
// --- panel: tab & judul bagian
'Pengaturan': 'Settings', 'Tampilan': 'Appearance', 'Animasi': 'Animations', 'Thumbnail': 'Thumbnails',
'Hover preview': 'Hover preview', 'Unduhan': 'Downloads', 'Lainnya': 'More',
'Umum': 'General', 'Header': 'Header', 'Daftar galeri': 'Gallery list', 'Reader': 'Reader',
'Saat halaman dimuat': 'On page load', 'Halaman galeri': 'Gallery page', 'Kartu & thumbnail': 'Cards & thumbnails',
'Saat di-scroll': 'While scrolling', 'Thumbnail galeri': 'Gallery thumbnails', 'Judul': 'Titles',
'Pratinjau saat hover': 'On hover', 'Panel unduhan': 'Download panel', 'Riwayat': 'History',
'Pintasan': 'Shortcuts', 'Diagnostik': 'Diagnostics', 'Tentang': 'About',
// --- panel: tampilan
'Bahasa antarmuka panel dan notifikasi': 'Interface language for panels and notifications',
'Mode gelap': 'Dark mode', 'Tema gelap untuk seluruh situs': 'Dark theme for the whole site',
'Warna aksen': 'Accent color', 'Warna tombol, slider, dan penanda': 'Color of buttons, sliders and highlights',
'Warna kustom': 'Custom color',
'Posisi notifikasi': 'Notification position',
'Tempat muncul pesan seperti "Kamus kanji siap"': 'Where messages such as "Kanji dictionary ready" appear',
'Kanan bawah': 'Bottom right', 'Kiri bawah': 'Bottom left', 'Tengah bawah': 'Bottom center',
'Kanan atas': 'Top right', 'Kiri atas': 'Top left', 'Tengah atas': 'Top center',
'Header tetap di atas': 'Sticky header', 'Navbar (menu & pencarian) tidak hilang saat halaman di-scroll': 'The navbar (menu & search) stays visible while scrolling',
'Perpanjang kolom search': 'Wider search box', 'Kolom pencarian di header dibuat lebih panjang': 'Makes the header search box longer',
'Lebar kolom search': 'Search box width', 'Lebar kolom pencarian (px)': 'Width of the search box (px)',
'Poster grid': 'Poster grid', 'Tampilkan galeri sebagai kartu poster': 'Show galleries as poster cards',
'Gaya kartu': 'Card style', 'Detail: cover + tabel info seperti halaman galeri. Poster: cover penuh': 'Detail: cover + info table like the gallery page. Poster: full cover',
'Detail': 'Detail', 'Poster': 'Poster',
'Ukuran kartu': 'Card size', 'Lebar minimum tiap kartu poster': 'Minimum width of each poster card',
'Mode webtoon': 'Webtoon mode', 'Scroll vertikal, semua halaman berurutan': 'Vertical scroll, all pages in sequence',
'Lebar gambar': 'Image width', 'Lebar halaman pada mode webtoon (px)': 'Page width in webtoon mode (px)', 'Penuh': 'Full',
// --- panel: animasi
'Animasi saat memuat': 'Load animation', 'Header dan kartu muncul dengan animasi saat halaman dimuat': 'Header and cards animate in when the page loads',
'Navbar (menu atas)': 'Navbar (top menu)', 'Efek masuk untuk logo, menu, dan kolom search': 'Entrance effect for the logo, menu and search box',
'Banner judul': 'Title banner', 'Efek masuk untuk bar "Recently Added" (halaman daftar)': 'Entrance effect for the "Recently Added" bar (list pages)',
'Cover & info': 'Cover & info', 'Efek masuk untuk cover, judul, dan tabel info saat membuka sebuah galeri': 'Entrance effect for the cover, title and info table when opening a gallery',
'Gaya animasi': 'Animation style', 'Pilih efek masuk tiap kartu. Perubahan langsung dipratinjau di halaman': 'Pick the entrance effect for each card. Changes are previewed on the page',
'Urutan muncul': 'Order', 'Berurutan: kiri ke kanan lalu ke bawah. Diagonal: menyapu dari pojok kiri-atas. Acak: urutan dikocok': 'Sequential: left to right, then down. Diagonal: sweeps from the top-left corner. Random: shuffled order',
'Berurutan (kiri-atas)': 'Sequential (top-left)', 'Diagonal': 'Diagonal', 'Acak': 'Random',
'Kecepatan': 'Speed', 'Durasi animasi tiap kartu': 'Animation duration of each card', 'Cepat': 'Fast', 'Normal': 'Normal', 'Lambat': 'Slow',
'Jeda antar kartu': 'Delay between cards', 'Selisih waktu muncul antar kartu (ms)': 'Time gap between cards appearing (ms)',
'Putar ulang': 'Replay', 'Pratinjau': 'Preview', 'Putar ulang animasi pada kartu yang sedang terlihat': 'Replay the animation on the cards currently visible',
'Animasi saat di-scroll': 'Scroll animation', 'Kartu dan thumbnail di bawah layar muncul dengan animasi ketika di-scroll sampai ke situ': 'Cards and thumbnails below the screen animate in as you scroll to them',
'Gaya saat di-scroll': 'Scroll style', 'Efek masuk untuk kartu yang baru terlihat. Coba scroll halaman untuk melihatnya': 'Entrance effect for newly visible cards. Try scrolling the page to see it',
'Sama dengan gaya kartu': 'Same as card style', 'Campur (acak tiap kartu)': 'Mixed (random per card)',
'Naik': 'Rise', 'Turun': 'Drop', 'Geser dari kiri': 'Slide from left', 'Pop (membal)': 'Pop (bouncy)', 'Balik 3D': '3D flip',
'Kabur ke jelas': 'Blur to sharp', 'Tirai (kiri ke kanan)': 'Curtain (left to right)', 'Fade polos': 'Plain fade',
'Geser dari kanan': 'Slide from right', 'Geser diagonal': 'Diagonal slide', 'Zoom mendekat': 'Zoom in',
'Memantul dari bawah': 'Bounce from below', 'Miring masuk': 'Tilt in', 'Geser miring': 'Skewed slide', 'Putar masuk': 'Spin in',
'Balik horizontal': 'Horizontal flip', 'Rebah 3D': '3D tilt', 'Iris (lingkaran membuka)': 'Iris (circle opens)',
'Tirai (bawah ke atas)': 'Curtain (bottom to top)',
'Tanpa animasi': 'No animation', 'Geser dari atas': 'Slide from top', 'Buka dari atas': 'Unfold from top', 'Zoom masuk': 'Zoom in',
'Turun memantul': 'Bouncing drop', 'Balik 3D (dari atas)': '3D flip (from top)', 'Iris (melebar dari tengah)': 'Iris (expands from center)',
'Bagian satu per satu': 'Part by part',
// --- panel: thumbnail, romaji, preview
'Thumbnail besar': 'Large thumbnails', 'Ganti strip thumbnail dengan grid besar': 'Replace the thumbnail strip with a large grid',
'Resolusi asli': 'Original resolution', 'Pakai gambar reader di thumbnail (berat). Mati = ringan': 'Use reader images for thumbnails (heavy). Off = light',
'Ukuran thumbnail': 'Thumbnail size', 'Lebar minimum tiap thumbnail': 'Minimum width of each thumbnail',
'Judul ke romaji': 'Titles to romaji', 'Ubah tulisan Jepang (hiragana, katakana, kanji) di judul jadi huruf latin. Arahkan kursor ke kartu untuk melihat judul asli': 'Convert Japanese text (hiragana, katakana, kanji) in titles to Latin letters. Hover a card to see the original title',
'Ubah kanji juga': 'Convert kanji too', 'Unduh kamus kanji sekali (±17 MB, lalu tersimpan di cache browser). Mati = hanya kana yang diubah': 'Downloads the kanji dictionary once (~17 MB, then cached by the browser). Off = only kana is converted',
'Vokal panjang': 'Long vowels', 'Cara menulis vokal panjang: toukyou atau tokyo': 'How long vowels are written: toukyou or tokyo',
'Sederhana': 'Simple', 'Apa adanya': 'As is',
'Tampilkan halaman besar saat kursor di thumbnail': 'Show a large page when the cursor is over a thumbnail',
'Ukuran preview': 'Preview size', 'Tinggi preview (% tinggi layar)': 'Preview height (% of screen height)',
'Jeda muncul': 'Show delay', 'Waktu tunggu sebelum preview muncul': 'Wait time before the preview appears',
'Posisi preview': 'Preview position', 'Di samping thumbnail atau mengikuti kursor': 'Beside the thumbnail or following the cursor',
'Samping': 'Side', 'Kursor': 'Cursor',
// --- panel: unduhan
'Panel pilih unduhan': 'Download picker', 'Klik Download → pilih gambar dulu; jalan di latar belakang antar halaman': 'Click Download → choose images first; runs in the background across pages',
'Hasil default': 'Default output', 'Bentuk file yang dihasilkan': 'Type of file produced', 'Gambar': 'Images', 'gambar': 'images',
'Kualitas JPG/PDF': 'JPG/PDF quality', 'Dipakai untuk PDF atau konversi JPG': 'Used for PDF or JPG conversion',
'Ukuran thumbnail panel': 'Panel thumbnail size', 'Lebar thumbnail di panel pilih gambar': 'Thumbnail width in the image picker',
'Tandai yang sudah diunduh': 'Mark downloaded galleries', 'Badge "✓ Diunduh" dan kartu diredupkan di daftar galeri': '"✓ Downloaded" badge and dimmed cards in the gallery list',
'Daftar sudah diunduh': 'Downloaded list', 'Reset semua penanda "diunduh"': 'Reset all "downloaded" marks',
'Hapus daftar ({0})': 'Clear list ({0})', 'Daftar sudah kosong': 'The list is already empty',
'Hapus daftar sudah diunduh?': 'Clear the downloaded list?',
'{0} galeri akan kehilangan penanda. File hasil unduhan tidak ikut terhapus.': '{0} galleries will lose their mark. Downloaded files are not deleted.',
'Hapus': 'Delete', 'Batal': 'Cancel', 'Daftar penanda dihapus': 'Downloaded marks cleared', '✓ Diunduh': '✓ Downloaded',
// --- panel: lainnya, footer
'Buka/tutup pengaturan': 'Open/close settings', 'Buka/tutup Filter Tag': 'Open/close Tag Filter',
'Dump latar terang': 'Light-background dump', 'Salin daftar elemen berlatar putih (untuk dicek)': 'Copy a list of elements with white backgrounds (for debugging)',
'Salin dump': 'Copy dump', 'Tersalin ✓': 'Copied ✓', 'Lihat console (F12)': 'See console (F12)',
'Poster grid, mode gelap, reader webtoon, dan filter tag untuk hitomi.la': 'Poster grid, dark mode, webtoon reader and tag filter for hitomi.la',
'Reset default': 'Reset to default', 'Reset semua pengaturan?': 'Reset all settings?',
'Semua pengaturan dikembalikan ke nilai awal.': 'All settings will be restored to their defaults.', 'Reset': 'Reset',
'Pengaturan dikembalikan ke default': 'Settings restored to default', 'Notifikasi akan muncul di sini': 'Notifications will appear here',
'Buka/tutup: ': 'Open/close: ', 'Tutup (Esc)': 'Close (Esc)', 'Konfirmasi': 'Confirm',
'⚙ Pengaturan': '⚙ Settings', 'Pengaturan (Alt+S)': 'Settings (Alt+S)',
// --- filter tag
'Filter Tag': 'Tag Filter', 'Filter Tag (Alt+T)': 'Tag Filter (Alt+T)', 'Sertakan': 'Include', 'Kecualikan': 'Exclude',
'Cari tag… (mis. female:insect)': 'Search tags… (e.g. female:insect)', 'Bersihkan': 'Clear',
'Tag belum dikenal. Pilih namespace di bawah (♀ / ♂ / tag).': 'Unknown tag. Pick a namespace below (♀ / ♂ / tag).',
'Pilih mode di atas, klik tag di bawah, lalu tekan Cari. Hasilnya dari seluruh galeri, bukan hanya halaman ini.': 'Pick a mode above, click tags below, then press Search. Results come from all galleries, not just this page.',
'Tambah tag baru': 'Add new tag', 'pilih jenisnya': 'choose its type', 'Tag di halaman ini': 'Tags on this page', 'Dari halaman lain': 'From other pages',
'Kosongkan': 'Clear', '🔍 Cari': '🔍 Search', '🔍 Cari ({0})': '🔍 Search ({0})', 'Pilih tag dulu': 'Pick a tag first',
'Tidak ada yang cocok di halaman ini.': 'No matches on this page.', 'Belum ada tag terbaca di halaman ini': 'No tags found on this page yet',
'{0} tag': '{0} tag{1}', '{0} dari {1} (ketik untuk cari)': '{0} of {1} (type to search)',
// --- kamus kanji
'Memuat kamus kanji… (pertama kali agak lama, halaman tetap bisa dipakai)': 'Loading kanji dictionary… (slow the first time; the page stays usable)',
'Gagal memuat kamus kanji ({0}). Kalau diblokir ekstensi pemblokir iklan, izinkan cdn.jsdelivr.net. Detail di Console, cari [HMT].': 'Failed to load the kanji dictionary ({0}). If an ad blocker is blocking it, allow cdn.jsdelivr.net. Details in the Console, search for [HMT].',
'tidak diketahui': 'unknown', 'Kamus kanji siap': 'Kanji dictionary ready',
'Gagal dimuat, klik untuk coba lagi': 'Failed to load, click to retry',
// --- folder, antrian & panel pilih gambar
'Pemilihan folder dibatalkan': 'Folder selection canceled', 'Tidak bisa mengakses folder: {0}': 'Cannot access the folder: {0}',
'Pilih folder tujuan': 'Choose destination folder', 'Pilih folder': 'Choose folder',
'Pilih atau buat SUBFOLDER sendiri (mis. Documents\\Hitomi). Browser menolak folder sistem seperti Downloads, Documents, atau Desktop langsung.': 'Pick or create your own SUBFOLDER (e.g. Documents\\Hitomi). The browser rejects system folders such as Downloads, Documents or Desktop directly.',
'Izin ke folder sebelumnya tidak tersedia.': 'Permission for the previous folder is not available.', 'Belum ada folder tujuan.': 'No destination folder yet.',
'Hapus yang sudah selesai / gagal': 'Remove finished / failed items', 'Ciutkan / buka': 'Collapse / expand', '{0} berjalan': '{0} running',
'Batalkan unduhan?': 'Cancel the download?', 'Batalkan': 'Cancel download', 'Lanjutkan': 'Keep going', 'Simpan': 'Save',
'Selesai: {0} → {1}': 'Done: {0} → {1}', 'Unduhan selesai, tinggal disimpan ke folder: {0}': 'Download finished, just save it to the folder: {0}',
'gagal': 'failed', 'Gagal': 'Failed', 'Semua gambar gagal diproses (lihat console F12)': 'All images failed to process (see console, F12)',
'Gagal menyimpan: {0}': 'Failed to save: {0}',
'toBlob gagal': 'toBlob failed', 'tidak ada URL': 'no URL', 'kamus gagal diunduh ({0})': 'dictionary download failed ({0})', 'jaringan/CORS': 'network/CORS',
'Menunggu antrian · {0} gambar': 'Queued · {0} images', 'Menyusun file…': 'Building file…', 'Mengunduh {0} / {1}': 'Downloading {0} / {1}',
' · konversi JPG': ' · JPG conversion', ' (gagal: {0})': ' (failed: {0})', 'Siap — klik Simpan untuk menulis ke folder': 'Ready — click Save to write to the folder',
'Selesai ✓ → {0}': 'Done ✓ → {0}', ' di folder "{0}"': ' in folder "{0}"',
'Pilih gambar untuk diunduh': 'Choose images to download', 'Pilih semua': 'Select all', 'Balik pilihan': 'Invert selection',
'Klik = pilih/batal · Shift+klik = rentang': 'Click = select/deselect · Shift+click = range',
'Ukuran': 'Size', 'Kualitas JPG': 'JPG quality', 'Format': 'Format', 'Asli': 'Original', 'Simpan ke': 'Save to', 'Hasil': 'Output', 'Folder': 'Folder',
'Pilih folder…': 'Choose folder…', 'Tutup': 'Close',
'{0} / {1} dipilih': '{0} / {1} selected', 'Simpan {0} ({1})': 'Save {0} ({1})', 'Unduh {0} ({1})': 'Download {0} ({1})',
'Siap mengunduh {0} gambar · berjalan di latar belakang, boleh pindah halaman': 'Ready to download {0} images · runs in the background, you can change pages',
'Belum ada gambar dipilih': 'No images selected',
'Galeri ini sudah pernah diunduh ({0}).': 'This gallery was already downloaded ({0}).', 'Hapus tanda': 'Remove mark',
'Gagal menambahkan ke antrian: {0}': 'Failed to add to the queue: {0}',
'Ditambahkan ke antrian: {0} gambar. Boleh pindah halaman, unduhan tetap jalan.': 'Added to queue: {0} images. You can change pages; the download keeps running.',
};
function T(s, ...a) {
if (typeof s !== 'string') return s;
const r = state.lang === 'en' && Object.prototype.hasOwnProperty.call(EN, s) ? EN[s] : s;
return a.length ? r.replace(/\{(\d+)\}/g, (m, i) => (a[+i] != null ? a[+i] : m)) : r;
}
const tErr = (e) => { // pesan galat yang tersimpan/dibawa dari worker (bahasa Indonesia) -> bahasa aktif
const x = String(e == null ? '' : e);
let m = x.match(/^Gagal menyimpan: ([\s\S]*)$/); if (m) return T('Gagal menyimpan: {0}', m[1]);
m = x.match(/^Error: ([\s\S]*)$/); if (m) return 'Error: ' + T(m[1]);
m = x.match(/^kamus gagal diunduh \(([\s\S]*)\)$/); if (m) return T('kamus gagal diunduh ({0})', T(m[1]));
return T(x);
};
const outName = (o) => T(o === 'pdf' ? 'PDF' : o === 'img' ? 'gambar' : 'ZIP');
const isReader = location.pathname.startsWith('/reader/');
const root = document.documentElement;
let trimmed = false; // true setelah halaman siap: pagination bawah boleh tampil lagi
const isGallery = !isReader && /^\/(doujinshi|manga|artistcg|gamecg|imageset|anime)\//.test(location.pathname);
let thumbHost = null; // grid thumbnail besar buatan script
let thumbOrig = null; // strip thumbnail asli situs (disembunyikan saat grid besar aktif)
let navRO = null, navAttrMO = null, stk = [], stkRaf = 0, stkReady = false, stkBanTry = 0; // header tetap di atas (fixed + spacer); [{ el, sp, mt, mb }]
let wtFailed = false; // true kalau webtoon gagal dibangun -> kembali ke reader asli
/* ====================================================================
* CSS
* ==================================================================== */
const css = `
.hmt-hide { display: none !important; }
/* pagination disembunyikan dulu sampai script memutuskan mana yang atas, supaya tidak muncul-hilang */
html.hm-trim .page-container { visibility: hidden; }
html.hm-dark { color-scheme: dark; }
/* banner judul halaman ("Recently Added"): gelap sejak awal, tanpa menunggu deteksi JS */
/* tanpa transisi: situs memberi transition pada latar, jadi saat digelapkan terlihat memudar dari terang -> kedip */
html.hm-dark .hmt-banner, html.hm-dark .hmt-banner *, html.hm-dark .hmt-dk, html.hm-dark .navbar, html.hm-dark .navbar * { transition: none !important; }
html.hm-dark .hmt-dk { background-color: #1a1a22 !important; color: #d6d6dc !important; border-color: #33334a !important; }
/* ---------- GRID ---------- */
html.hm-grid .container { width: 96% !important; max-width: 1900px !important; }
html.hm-grid .gallery-content { display: none !important; }
.hm-grid-wrap { display: none; }
html.hm-grid .hm-grid-wrap {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--hm-card, 190px), 1fr));
gap: 12px; padding: 12px; box-sizing: border-box; width: 100%;
}
.hm-card {
position: relative; display: block; overflow: hidden; aspect-ratio: 3 / 4.2;
border-radius: 8px; background: #1d1d27; border: 1px solid #33334a;
text-decoration: none !important;
transition: transform .15s, box-shadow .15s, border-color .15s;
}
.hm-card:hover { transform: translateY(-2px); border-color: var(--hm-ac, #6b8cff); box-shadow: 0 6px 18px rgba(0,0,0,.45); z-index: 2; }
.hm-card img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s; }
.hm-card:hover img { transform: scale(1.04); }
.hm-tags {
position: absolute; inset: 0; padding: 30px 8px 74px; box-sizing: border-box;
display: flex; flex-wrap: wrap; align-content: flex-start; gap: 3px; overflow: hidden;
background: rgba(10,10,18,.88); opacity: 0; transition: opacity .15s; pointer-events: none;
}
.hm-card:hover .hm-tags { opacity: 1; }
.hm-tags span { font: 10px/1 sans-serif; padding: 3px 5px; border-radius: 3px; background: #3b3f5c; color: #e6e8ff; }
.hm-badges { position: absolute; top: 6px; left: 6px; right: 6px; display: flex; justify-content: space-between; pointer-events: none; }
.hm-badge { font: 700 10px/1 sans-serif; padding: 3px 5px; border-radius: 4px; background: rgba(0,0,0,.75); color: #fff; }
.hm-ov {
position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 8px 7px;
background: linear-gradient(transparent, rgba(0,0,0,.92)); pointer-events: none;
}
.hm-title {
font: 700 12.5px/1.25 sans-serif; color: #fff; text-shadow: 0 1px 2px #000; word-break: break-word;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.hm-sub { font: 11px/1.3 sans-serif; color: var(--hm-act, #b9c4ff); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ---------- KARTU DETAIL (cover kiri + tabel info kanan) ---------- */
.hm-cover { position: absolute; inset: 0; overflow: hidden; }
.hm-info { display: none; }
html.hm-dt .hm-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(calc(var(--hm-card, 190px) * 2.3), 1fr)); }
html.hm-dt .hm-card { display: flex; align-items: stretch; aspect-ratio: auto; background: #1a1a22; }
html.hm-dt .hm-cover { position: relative; inset: auto; flex: 0 0 calc(var(--hm-card, 190px) * .78); aspect-ratio: 3 / 4.2; }
html.hm-dt .hm-cover .hm-tags, html.hm-dt .hm-cover .hm-ov, html.hm-dt .hm-cover .hm-badges { display: none !important; }
html.hm-dt .hm-info { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; overflow: hidden; }
html.hm-dt .hm-it { padding: 8px 10px; background: var(--tc, #4a3340); color: #fff; font: 700 15px/1.25 sans-serif; word-break: break-word; flex: 0 0 auto; }
html.hm-dt .hm-ia { flex: 0 0 auto; padding: 4px 10px; background: var(--ta, #33252f); color: var(--tt, #ffb3c8); font: 700 13px/1.3 sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
html.hm-dt .hm-rows { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 14px; padding: 9px 10px; font: 12px/1.35 sans-serif; align-content: start; flex: 1 1 0; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: #4a4e70 transparent; overscroll-behavior: contain; }
html.hm-dt .hm-k { font-weight: 700; color: #fff; }
html.hm-dt .hm-v { color: #cfcfd8; word-break: break-word; }
/* ---------- WARNA PER TYPE: --tc = bar judul, --ta = bar artis, --tt = teks aksen ---------- */
.hm-card[data-type="doujinshi"] { --tc: #4a3340; --ta: #33252f; --tt: #ffb3c8; }
.hm-card[data-type="manga"] { --tc: #2d4a73; --ta: #213552; --tt: #9fc8ff; }
.hm-card[data-type="imageset"] { --tc: #2c5c4c; --ta: #1f4136; --tt: #8fe8c3; }
.hm-card[data-type="artistcg"] { --tc: #5b3a78; --ta: #3f2756; --tt: #d9aaff; }
.hm-card[data-type="gamecg"] { --tc: #6a4f22; --ta: #4a3817; --tt: #ffd277; }
.hm-card[data-type="anime"] { --tc: #2b5a63; --ta: #1e4047; --tt: #8fe3f0; }
.hm-card[data-type] .hm-vtype { display: inline-block; justify-self: start; padding: 2px 8px; border-radius: 10px; font-weight: 700; background: var(--tc); color: var(--tt); box-shadow: inset 0 0 0 1px var(--tt); }
.hm-card[data-type] .hm-badge:last-child { background: var(--tc); color: var(--tt); box-shadow: inset 0 0 0 1px var(--tt); }
html.hm-dt .hm-chips { display: flex; flex-wrap: wrap; gap: 4px; }
html.hm-dt .hm-chips span { font: 700 11px/1 sans-serif; padding: 4px 7px; border-radius: 4px; background: #3d4059; color: #e4e6ff; }
html.hm-dt .hm-done { left: auto; right: 6px; top: 6px; }
/* ---------- INTRO KARTU: kartu disembunyikan sebentar, lalu diputar animasinya satu per satu (lihat introWatch) ---------- */
html.hmt-pre .hm-card, html.hmt-pre .hm-th { opacity: 0 !important; }
[data-hmt-sr] { opacity: 0 !important; } /* menunggu masuk layar (animasi saat di-scroll) */
html.hmt-pre-blk .cover-column, html.hmt-pre-blk .dj-gallery, html.hmt-pre-blk #gallery-brand, html.hmt-pre-blk .gallery-info { opacity: 0 !important; }
html.hmt-pre-nav .navbar, html.hmt-pre-bn .hmt-banner:not(.navbar), html.hmt-pre-bn .list-title { opacity: 0 !important; }
html.hmt-pre-navi .navbar *, html.hmt-pre-bni .hmt-banner:not(.navbar) *, html.hmt-pre-bni .list-title * { opacity: 0 !important; }
/* ---------- DARK ---------- */
html.hm-dark, html.hm-dark body { background: #14141a !important; color: #d6d6dc !important; }
html.hm-dark .container, html.hm-dark .content, html.hm-dark .top-content,
html.hm-dark .page-container, html.hm-dark .bottom-content,
html.hm-dark .dj-gallery, html.hm-dark .cover-column {
background: #14141a !important; color: #d6d6dc !important;
}
html.hm-dark td, html.hm-dark th, html.hm-dark .dj-content, html.hm-dark .dj-desc,
html.hm-dark .gallery-info, html.hm-dark .gallery-info * { color: #cfcfd8 !important; }
html.hm-dark h1, html.hm-dark h2 { color: #fff !important; }
html.hm-dark a { color: #8fb0ff; }
/* navbar tetap putih */
html.hm-dark .navbar a, html.hm-dark .navbar-inner a, html.hm-dark .drop-button a { color: #fff !important; }
html.hm-dark #lang-list { background: #22222e !important; }
html.hm-dark #lang-list a { color: #ddd !important; }
html.hm-dark #search-suggestions, html.hm-dark #search-suggestions * { background: #22222e !important; color: #ddd !important; }
/* header judul gallery */
html.hm-dark #gallery-brand { background: #4a3340 !important; }
html.hm-dark #gallery-brand, html.hm-dark #gallery-brand a { color: #fff !important; }
html.hm-dark #artists { background: #33252f !important; }
html.hm-dark #artists a { color: #ffb3c8 !important; }
/* tag chip */
html.hm-dark .tags a { background: #3d4059 !important; color: #e4e6ff !important; }
/* area thumbnail */
html.hm-dark .gallery-preview, html.hm-dark .gallery-preview div { background: #1a1a22 !important; border-color: #33334a !important; }
html.hm-dark .gallery-preview img { opacity: .9; }
html.hm-dark .page-container li, html.hm-dark .page-container li a { color: #aab !important; background: transparent !important; }
html.hm-dark input, html.hm-dark select { background: #2a2a36 !important; color: #ddd !important; border: 1px solid #444 !important; }
/* ---------- NAVBAR FIX + HAPUS GLOW PUTIH ---------- */
html.hm-grid .navbar { width: auto !important; max-width: none !important; }
html.hm-dark body, html.hm-dark .container, html.hm-dark .content, html.hm-dark .top-content,
html.hm-dark .page-container, html.hm-dark .bottom-content, html.hm-dark .dj-gallery,
html.hm-dark .cover-column, html.hm-dark .gallery-content, html.hm-dark .navbar, html.hm-dark .navbar-inner {
box-shadow: none !important; -webkit-box-shadow: none !important; filter: none !important;
}
html.hm-dark .container, html.hm-dark .content { border-color: transparent !important; }
#hmt-nav a { cursor: pointer; }
/* ---------- THUMBNAIL BESAR + HOVER PREVIEW ---------- */
.hm-thumbs { display: none; }
html.hm-thumbs-on .hm-thumbs {
display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--hm-tw, 160px), 1fr));
gap: 10px; padding: 10px; align-items: start; box-sizing: border-box; width: 100%;
}
.hm-th { position: relative; display: block; line-height: 0; overflow: hidden; border-radius: 6px;
background: #1d1d27; border: 1px solid #33334a; transition: border-color .12s; }
.hm-th:hover { border-color: var(--hm-ac, #6b8cff); }
.hm-th img { display: block; width: 100%; height: auto; background: #1a1a22; }
.hm-thn { position: absolute; left: 6px; bottom: 6px; padding: 2px 6px; border-radius: 4px;
background: rgba(0,0,0,.72); color: #fff; font: 700 11px/1.3 sans-serif; pointer-events: none; }
#hmt-preview { position: fixed; left: 0; top: 0; z-index: 99998; display: none; pointer-events: none; line-height: 0;
background: #0c0c10; border: 1px solid #33334a; border-radius: 8px; overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,.6); will-change: transform; }
#hmt-preview img { display: block; max-width: none !important; }
.hmt-pv-n { position: absolute; left: 8px; bottom: 8px; padding: 2px 7px; border-radius: 5px;
background: rgba(0,0,0,.72); color: #fff; font: 700 12px/1.3 sans-serif; }
/* ---------- PANEL PENGATURAN ---------- */
#hmt-overlay { position: fixed; inset: 0; z-index: 100000; display: none; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box;
background: rgba(8,8,14,.72); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); transition: background-color .25s; }
#hmt-overlay.open { display: flex; animation: hmt-fade .16s ease-out; }
#hmt-overlay.open #hmt-panel { animation: hmt-pop .22s cubic-bezier(.22, 1, .36, 1); }
#hmt-overlay.hmt-still, #hmt-overlay.hmt-still #hmt-panel { animation: none !important; }
@keyframes hmt-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes hmt-pop { from { opacity: 0; transform: translateY(12px) scale(.98); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { #hmt-overlay.open, #hmt-overlay.open #hmt-panel { animation: none; } }
#hmt-overlay.hmt-pv { background: rgba(8,8,14,0) !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
#hmt-panel { --p-card: #1f1f2b; --p-line: #2c2c3e; --p-mut: #8d8da8;
width: min(840px, 100%); height: min(600px, 100%); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
background: #171720; color: #e8e8f2; border: 1px solid #2f2f44; border-radius: 16px;
box-shadow: 0 28px 80px rgba(0,0,0,.7); font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; }
#hmt-panel *, #hmt-panel *::before, #hmt-panel *::after { box-sizing: border-box; }
#hmt-panel button { all: unset; box-sizing: border-box; cursor: pointer; }
#hmt-panel button:focus-visible, #hmt-panel .hmt-sel:focus-visible { outline: 2px solid var(--hm-ac, #5b7cff); outline-offset: 2px; }
/* kepala */
#hmt-panel .hmt-head { flex: none; display: flex; align-items: center; gap: 12px; padding: 14px 16px 14px 18px; border-bottom: 1px solid var(--p-line);
background: linear-gradient(180deg, #1e1e2a, #171720); }
#hmt-panel .hmt-logo { flex: none; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; font-size: 18px; color: #fff;
background: var(--hm-acbg, #2c3050); box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
#hmt-panel .hmt-ttl { flex: 1; min-width: 0; }
#hmt-panel .hmt-title { font-size: 16px; font-weight: 700; color: #fff; line-height: 1.2; }
#hmt-panel .hmt-sub { margin-top: 2px; font-size: 12px; color: var(--p-mut); letter-spacing: .02em; }
#hmt-panel .hmt-sub b { color: var(--hm-act, #b9c4ff); font-weight: 700; }
#hmt-panel .hmt-close { flex: none; width: 32px; height: 32px; border-radius: 9px; text-align: center; line-height: 32px; font-size: 16px; color: #9a9ab5; }
#hmt-panel .hmt-close:hover { background: #2a2a3b; color: #fff; }
/* badan: menu kiri + isi */
#hmt-panel .hmt-body { flex: 1; min-height: 0; display: flex; }
#hmt-panel .hmt-nav { flex: none; width: 184px; padding: 12px 10px; border-right: 1px solid var(--p-line); background: #14141b;
display: flex; flex-direction: column; gap: 3px; overflow-y: auto; scrollbar-width: thin; }
#hmt-panel .hmt-tab { position: relative; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; color: #9a9ab5; font-weight: 600; font-size: 13px; transition: background .12s, color .12s; }
#hmt-panel .hmt-tab:hover { background: #20202e; color: #fff; }
#hmt-panel .hmt-tab.on { background: var(--hm-acbg, #2c3050); color: #fff; }
#hmt-panel .hmt-tab.on::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--hm-ac, #5b7cff); }
#hmt-panel .hmt-ic { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; background: rgba(255,255,255,.06); font-size: 14px; line-height: 1; }
#hmt-panel .hmt-tab.on .hmt-ic { background: rgba(255,255,255,.12); }
#hmt-panel .hmt-pane { display: none; flex: 1; min-width: 0; overflow-y: auto; padding: 14px 20px 22px; scrollbar-width: thin; scrollbar-color: #3a3a50 transparent; }
#hmt-panel .hmt-pane.on { display: block; }
/* bagian & baris */
#hmt-panel .hmt-sec { margin: 20px 4px 8px; font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: #7f86b8; }
#hmt-panel .hmt-pane > .hmt-sec:first-child { margin-top: 2px; }
#hmt-panel .hmt-grp { background: var(--p-card); border: 1px solid var(--p-line); border-radius: 12px; overflow: hidden; }
#hmt-panel .hmt-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; transition: opacity .15s; }
#hmt-panel .hmt-row + .hmt-row { border-top: 1px solid var(--p-line); }
#hmt-panel .hmt-row.col { flex-direction: column; align-items: stretch; gap: 8px; }
#hmt-panel .hmt-row.hmt-clk { cursor: pointer; }
#hmt-panel .hmt-row.hmt-clk:hover { background: rgba(255,255,255,.025); }
#hmt-panel .hmt-row.hmt-dis { opacity: .4; pointer-events: none; }
#hmt-panel .hmt-txt { flex: 1; min-width: 0; }
#hmt-panel .hmt-lbl { color: #ececf5; font-weight: 600; }
#hmt-panel .hmt-desc { color: var(--p-mut); font-size: 12px; margin-top: 2px; line-height: 1.4; }
/* kontrol */
#hmt-panel .hmt-sw { position: relative; flex: none; width: 42px; height: 24px; border-radius: 12px; background: #3a3a50; transition: background .18s; }
#hmt-panel .hmt-sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .18s; }
#hmt-panel .hmt-sw[aria-checked="true"] { background: var(--hm-ac, #5b7cff); }
#hmt-panel .hmt-sw[aria-checked="true"]::after { transform: translateX(18px); }
#hmt-panel .hmt-rangebox { flex: none; width: 230px; display: flex; align-items: center; gap: 12px; }
#hmt-panel input.hmt-range { -webkit-appearance: none; appearance: none; flex: 1; height: 6px; border-radius: 3px; padding: 0; margin: 0; background: #3a3a50 !important; border: 0 !important; outline: none; }
#hmt-panel input.hmt-range::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; cursor: pointer; }
#hmt-panel input.hmt-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; cursor: pointer; }
#hmt-panel .hmt-val { min-width: 56px; text-align: right; color: var(--hm-act, #b9c4ff); font-variant-numeric: tabular-nums; font-weight: 600; }
#hmt-panel .hmt-seg { flex: none; width: 230px; display: flex; gap: 3px; padding: 3px; border-radius: 10px; background: #12121a; border: 1px solid var(--p-line); }
#hmt-panel .hmt-seg button { flex: 1; text-align: center; padding: 6px 4px; border-radius: 7px; color: #9a9ab5; font-weight: 600; font-size: 12.5px; white-space: nowrap; }
#hmt-panel .hmt-seg button:hover { color: #fff; }
#hmt-panel .hmt-seg button.on { background: var(--hm-ac, #5b7cff); color: #fff; }
#hmt-panel .hmt-sel { flex: none; width: 230px; padding: 8px 10px; border-radius: 9px; border: 1px solid #33334a; background: #12121a; color: #e6e6f0; font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1.2; cursor: pointer; outline: none; }
#hmt-panel .hmt-sel:focus { border-color: var(--hm-ac, #5b7cff); }
#hmt-panel .hmt-sel option { background: #14141c; color: #e6e6f0; }
#hmt-panel .hmt-reset { display: inline-block; padding: 7px 13px; border-radius: 8px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 12px; white-space: nowrap; text-align: center; }
#hmt-panel .hmt-reset:hover { background: #3a3a52; color: #fff; }
#hmt-panel .hmt-reset.warn:hover { background: #4b2733; color: #ffc0cc; }
#hmt-panel kbd { font: 11px/1 ui-monospace, monospace; padding: 3px 6px; border-radius: 5px; background: #12121a; border: 1px solid #33334a; color: var(--hm-act, #b9c4ff); }
#hmt-panel .hmt-badge { flex: none; padding: 5px 11px; border-radius: 999px; background: var(--hm-acbg, #2c3050); color: #fff; font-size: 12px; font-weight: 600; }
#hmt-panel .hmt-badge b { color: var(--hm-act, #b9c4ff); }
/* kaki */
#hmt-panel .hmt-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--p-line); background: #14141b; color: #7a7a95; font-size: 12px; }
/* layar kecil */
@media (max-width: 600px) {
#hmt-overlay { padding: 8px; }
#hmt-panel { height: 100%; }
#hmt-panel .hmt-body { flex-direction: column; }
#hmt-panel .hmt-nav { width: auto; flex-direction: row; border-right: 0; border-bottom: 1px solid var(--p-line); padding: 8px; overflow-x: auto; overflow-y: hidden; }
#hmt-panel .hmt-tab { padding: 7px 10px; white-space: nowrap; }
#hmt-panel .hmt-tab.on::before { display: none; }
#hmt-panel .hmt-pane { padding: 12px 12px 18px; }
#hmt-panel .hmt-row { flex-wrap: wrap; }
#hmt-panel .hmt-rangebox, #hmt-panel .hmt-seg, #hmt-panel .hmt-sel { width: 100%; min-width: 0; }
}
#hmt-fab { position: fixed; right: 10px; bottom: 10px; z-index: 99999; width: 34px; height: 34px; border-radius: 50%; border: 1px solid #444; background: #222; color: #fff; cursor: pointer; opacity: .5; padding: 0; font-size: 16px; }
#hmt-fab:hover { opacity: 1; }
/* ---------- READER / WEBTOON ---------- */
html.hm-webtoon { overflow-x: hidden !important; overflow-y: auto !important; height: auto !important; background: #0c0c10 !important; }
html.hm-webtoon body { overflow: visible !important; height: auto !important; min-height: 100vh; background: #0c0c10 !important; }
html.hm-webtoon #comicImages, html.hm-webtoon #mobileImages,
html.hm-webtoon #preload, html.hm-webtoon #postload { display: none !important; }
html.hm-webtoon .navbar-static-top { position: sticky !important; top: 0; z-index: 1000; }
/* header tetap di atas saat scroll */
.hmt-nav-fixed { position: fixed !important; z-index: 1000 !important; margin: 0 !important; box-sizing: border-box; background: #fff; }
.hmt-banner.hmt-nav-fixed { z-index: 999 !important; }
/* navbar + banner judul disembunyikan sampai posisi tetapnya siap (tidak ada loncat/kedip saat halaman dimuat) */
html.hm-sticky:not(.hmt-ready) .navbar, html.hm-sticky:not(.hmt-ready) .container *:has(select):not(:has(.navbar, .gallery-content, .hm-grid-wrap, .page-container, .gallery-preview)):not(.navbar *):not(.gallery-content *):not(.hm-grid-wrap *) { visibility: hidden !important; }
html.hm-dark .navbar, html.hm-dark .navbar-inner { background-color: #14141a !important; background-image: none !important; }
html.hm-dark .hmt-nav-fixed, html.hm-dark .hmt-banner.hmt-nav-fixed { background: #14141a !important; background-image: none !important; }
/* banner judul: polos seperti navbar, satu baris */
.hmt-banner.hmt-nav-fixed { background-image: none !important; display: flex !important; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 12px !important; min-height: 40px; height: auto !important; overflow: visible !important; }
.hmt-banner.hmt-nav-fixed .hmt-bt, .hmt-banner.hmt-nav-fixed :is(h1, h2, h3, h4) { position: static !important; float: none !important; transform: none !important; margin: 0 !important; padding: 0 !important; font-size: 22px !important; line-height: 1.2 !important; text-shadow: none !important; display: block; white-space: nowrap; }
.hmt-banner.hmt-nav-fixed .hmt-bt:has(~ *), .hmt-banner.hmt-nav-fixed :is(h1, h2, h3, h4):has(~ *) { flex: 1 1 auto; }
.hmt-banner.hmt-nav-fixed select { float: none !important; position: static !important; margin: 0 !important; }
html.hm-webtoon li:has(> #nextPanel), html.hm-webtoon li:has(> #prevPanel),
html.hm-webtoon li:has(> #fitVertical), html.hm-webtoon li:has(> #fitHorizontal),
html.hm-webtoon li:has(> #fullSpread), html.hm-webtoon li:has(> #singlePage),
html.hm-webtoon li:has(> #offsetPage) { display: none !important; }
html:not(.hm-webtoon) #wt-container, html:not(.hm-webtoon) #wt-counter { display: none !important; }
#wt-container { display: flex; flex-direction: column; align-items: center; width: 100%; padding: 0 0 80px; }
#wt-container img { display: block; width: var(--hm-rw, 800px); max-width: 100%; height: auto; margin: 0; background: #1a1a22; }
#wt-container img.wt-fail { min-height: 220px; outline: 2px dashed #a44; cursor: pointer; }
#wt-counter { position: fixed; left: 10px; bottom: 10px; z-index: 99999; background: rgba(0,0,0,.7); color: #fff; padding: 4px 10px; border-radius: 12px; font: 13px sans-serif; }
/* ---------- PANEL PILIH UNDUHAN (tanpa backdrop: situs di belakang tetap bisa dipakai) ---------- */
#hmt-dl { position: fixed; inset: 0; z-index: 100001; pointer-events: none; color: #e6e6f0; text-align: left;
font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; }
#hmt-dl *, #hmt-dl *::before, #hmt-dl *::after { box-sizing: border-box; }
#hmt-dl button { all: unset; box-sizing: border-box; cursor: pointer; }
#hmt-dl .dl-box { position: fixed; pointer-events: auto; max-width: 100vw; max-height: 100vh; display: flex; flex-direction: column;
background: #1b1b25; border: 1px solid #33334a; border-radius: 16px; box-shadow: 0 24px 70px rgba(0,0,0,.65); overflow: hidden; }
#hmt-dl .dl-box.min { height: auto !important; min-height: 0 !important; }
#hmt-dl .dl-box.min .dl-bar, #hmt-dl .dl-box.min .dl-grid, #hmt-dl .dl-box.min .dl-foot, #hmt-dl .dl-box.min .dl-rs { display: none !important; }
#hmt-dl .dl-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #2b2b3b;
cursor: move; user-select: none; touch-action: none; }
#hmt-dl .dl-title { font-size: 17px; font-weight: 700; color: #fff; }
#hmt-dl .dl-sub { font-size: 12px; color: #8b8ba6; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#hmt-dl .dl-hb { display: flex; gap: 4px; flex: none; }
#hmt-dl .dl-x { flex: none; width: 30px; height: 30px; border-radius: 8px; text-align: center; line-height: 30px; font-size: 18px; color: #9a9ab5; }
#hmt-dl .dl-x:hover { background: #2a2a3b; color: #fff; }
#hmt-dl .dl-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 10px 18px; border-bottom: 1px solid #2b2b3b; }
#hmt-dl .dl-btn { padding: 7px 12px; border-radius: 8px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 12px; }
#hmt-dl .dl-btn:hover { background: #3a3a52; color: #fff; }
#hmt-dl .dl-btn.pri { background: var(--hm-ac, #5b7cff); color: #fff; font-size: 13px; padding: 9px 20px; }
#hmt-dl .dl-btn.pri:hover { background: var(--hm-ac2, #6f8cff); }
#hmt-dl .dl-btn.off { opacity: .4; pointer-events: none; }
#hmt-dl .dl-lbl { font-size: 12px; font-weight: 600; color: #8b8ba6; margin-left: 6px; }
#hmt-dl .dl-seg { display: flex; gap: 3px; padding: 3px; border-radius: 9px; background: #14141c; }
#hmt-dl .dl-seg button { padding: 5px 12px; border-radius: 6px; color: #9a9ab5; font-weight: 600; font-size: 12px; }
#hmt-dl .dl-seg button:hover { color: #fff; }
#hmt-dl .dl-seg button.on { background: var(--hm-ac, #5b7cff); color: #fff; }
#hmt-dl .dl-q { display: flex; align-items: center; gap: 8px; }
#hmt-dl .dl-q input { -webkit-appearance: none; appearance: none; width: 110px; height: 6px; border-radius: 3px; padding: 0; margin: 0; background: #3a3a50 !important; border: 0 !important; outline: none; cursor: pointer; }
#hmt-dl .dl-q input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; }
#hmt-dl .dl-q input::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; }
#hmt-dl .dl-q span { min-width: 38px; color: var(--hm-act, #b9c4ff); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
#hmt-dl .dl-hide { display: none !important; }
#hmt-dl .dl-folder { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#hmt-dl .dl-count { margin-left: auto; color: var(--hm-act, #b9c4ff); font-weight: 600; font-variant-numeric: tabular-nums; }
#hmt-dl .dl-hint { font-size: 11px; color: #7a7a95; }
/* spasi pakai padding pada .dl-item (bukan gap), supaya tidak bisa ditimpa CSS situs */
#hmt-dl .dl-grid { flex: 1; min-height: 0; overflow-y: auto; display: grid; gap: 0;
grid-template-columns: repeat(auto-fill, minmax(var(--dl-th, 150px), 1fr)); grid-auto-rows: max-content;
padding: 12px 14px 20px; align-content: start; }
#hmt-dl .dl-item { padding: 9px; cursor: pointer; user-select: none; }
#hmt-dl .dl-cell { position: relative; aspect-ratio: 3 / 4.2; border-radius: 10px; overflow: hidden; border: 2px solid #33334a;
background: #14141c; box-shadow: 0 3px 10px rgba(0,0,0,.35); transition: border-color .12s, transform .12s; }
#hmt-dl .dl-item:hover .dl-cell { transform: translateY(-2px); }
#hmt-dl .dl-cell img { display: block; width: 100%; height: 100%; object-fit: contain; transition: opacity .12s, filter .12s; pointer-events: none; }
#hmt-dl .dl-cell.on { border-color: var(--hm-ac, #5b7cff); }
#hmt-dl .dl-cell.off img { opacity: .28; filter: grayscale(1); }
#hmt-dl .dl-n { position: absolute; left: 6px; bottom: 6px; padding: 2px 6px; border-radius: 4px; background: rgba(0,0,0,.72); color: #fff; font: 700 11px/1.3 sans-serif; }
#hmt-dl .dl-ck { position: absolute; right: 6px; top: 6px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,.6);
border: 2px solid #fff; color: #fff; font: 700 12px/18px sans-serif; text-align: center; }
#hmt-dl .dl-cell.on .dl-ck { background: var(--hm-ac, #5b7cff); border-color: var(--hm-ac, #5b7cff); }
#hmt-dl .dl-foot { display: flex; align-items: center; gap: 14px; padding: 12px 18px; border-top: 1px solid #2b2b3b; }
#hmt-dl .dl-stat { flex: 1; min-width: 0; font-size: 12px; color: #8b8ba6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* handle resize di semua tepi & sudut */
#hmt-dl .dl-rs { position: absolute; z-index: 6; touch-action: none; }
#hmt-dl .dl-rs-n { top: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
#hmt-dl .dl-rs-s { bottom: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
#hmt-dl .dl-rs-e { right: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
#hmt-dl .dl-rs-w { left: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
#hmt-dl .dl-rs-ne { top: 0; right: 0; width: 14px; height: 14px; cursor: nesw-resize; }
#hmt-dl .dl-rs-nw { top: 0; left: 0; width: 14px; height: 14px; cursor: nwse-resize; }
#hmt-dl .dl-rs-se { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; }
#hmt-dl .dl-rs-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; }
/* ---------- WIDGET ANTRIAN UNDUHAN (tetap tampil di semua halaman) ---------- */
#hmt-dm { position: fixed; right: 16px; bottom: 16px; width: 340px; max-width: calc(100vw - 16px); z-index: 100002;
background: #1b1b25; color: #e6e6f0; border: 1px solid #33334a; border-radius: 12px; overflow: hidden; text-align: left;
box-shadow: 0 14px 40px rgba(0,0,0,.55); font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; }
#hmt-dm *, #hmt-dm *::before, #hmt-dm *::after { box-sizing: border-box; }
#hmt-dm.hide { display: none !important; }
#hmt-dm button { all: unset; box-sizing: border-box; cursor: pointer; }
#hmt-dm .dm-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 12px; background: #20202c;
font-weight: 700; cursor: move; user-select: none; touch-action: none; }
#hmt-dm .dm-hb { display: flex; gap: 2px; }
#hmt-dm .dm-hb button { padding: 2px 8px; border-radius: 6px; color: #9a9ab5; font-size: 12px; font-weight: 600; }
#hmt-dm .dm-hb button:hover { background: #2f2f44; color: #fff; }
#hmt-dm .dm-body { max-height: 46vh; overflow-y: auto; padding: 2px 12px 8px; }
#hmt-dm.min .dm-body { display: none; }
#hmt-dm .dm-job { padding: 10px 0; border-top: 1px solid #2b2b3b; }
#hmt-dm .dm-job:first-child { border-top: 0; }
#hmt-dm .dm-t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#hmt-dm .dm-s { font-size: 12px; color: #8b8ba6; margin: 2px 0 7px; }
#hmt-dm .dm-track { height: 6px; border-radius: 3px; background: #3a3a50; overflow: hidden; }
#hmt-dm .dm-fill { height: 100%; width: 0; background: var(--hm-ac, #5b7cff); transition: width .25s; }
#hmt-dm .dm-job.done .dm-fill, #hmt-dm .dm-job.ready .dm-fill { background: #3ecf8e; }
#hmt-dm .dm-job.error .dm-fill { background: #ff6b6b; }
#hmt-dm .dm-a { display: flex; gap: 6px; margin-top: 8px; justify-content: flex-end; }
#hmt-dm .dm-a button { padding: 4px 11px; border-radius: 7px; background: #2a2a3b; color: #ddd; font-size: 12px; font-weight: 600; }
#hmt-dm .dm-a button:hover { background: #3a3a52; color: #fff; }
#hmt-dm .dm-a button.pri { background: var(--hm-ac, #5b7cff); color: #fff; }
/* ---------- NOTIFIKASI (toast) & KONFIRMASI buatan sendiri ---------- */
#hmt-toasts { position: fixed; z-index: 100010; display: flex; flex-direction: column;
gap: 8px; align-items: stretch; pointer-events: none; width: min(420px, calc(100vw - 24px)); }
/* posisi notifikasi (diatur di Pengaturan > Tampilan). bawah-kanan diberi jarak supaya tidak menimpa tombol ⚙ */
#hmt-toasts, html[data-hmt-tp="br"] #hmt-toasts { right: 14px; bottom: 56px; left: auto; top: auto; transform: none; }
html[data-hmt-tp="bl"] #hmt-toasts { left: 14px; bottom: 22px; right: auto; top: auto; transform: none; }
html[data-hmt-tp="bc"] #hmt-toasts { left: 50%; bottom: 22px; right: auto; top: auto; transform: translateX(-50%); }
html[data-hmt-tp="tr"] #hmt-toasts { right: 14px; top: 96px; left: auto; bottom: auto; transform: none; }
html[data-hmt-tp="tl"] #hmt-toasts { left: 14px; top: 96px; right: auto; bottom: auto; transform: none; }
html[data-hmt-tp="tc"] #hmt-toasts { left: 50%; top: 96px; right: auto; bottom: auto; transform: translateX(-50%); }
.hmt-toast { pointer-events: auto; display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 12px 11px 14px; border-radius: 12px;
background: #20202c; color: #eceef8; border: 1px solid #3a3a52; border-left: 4px solid var(--hm-ac, #5b7cff); box-shadow: 0 10px 30px rgba(0,0,0,.5);
font: 13px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; animation: hmt-in .18s ease-out; }
.hmt-toast.ok { border-left-color: #3ecf8e; }
.hmt-toast.err { border-left-color: #ff6b6b; }
.hmt-toast.out { opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s; }
.hmt-toast .t-ico { flex: none; font-weight: 700; }
.hmt-toast.ok .t-ico { color: #3ecf8e; } .hmt-toast.err .t-ico { color: #ff6b6b; } .hmt-toast.info .t-ico { color: var(--hm-act, #8fa5ff); }
.hmt-toast .t-msg { flex: 1; min-width: 0; word-break: break-word; }
.hmt-toast button { all: unset; box-sizing: border-box; cursor: pointer; padding: 4px 10px; border-radius: 7px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 12px; }
.hmt-toast button:hover { background: #3a3a52; color: #fff; }
.hmt-toast button.pri { background: var(--hm-ac, #5b7cff); color: #fff; }
.hmt-toast button.x { padding: 2px 7px; color: #8b8ba6; font-size: 14px; background: transparent; }
@keyframes hmt-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
#hmt-modal { position: fixed; inset: 0; z-index: 100020; display: flex; align-items: center; justify-content: center; background: rgba(8,8,14,.6);
font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; }
#hmt-modal .m-box { width: min(380px, calc(100vw - 32px)); padding: 20px; border-radius: 14px; background: #1b1b25; color: #e6e6f0;
border: 1px solid #33334a; box-shadow: 0 24px 70px rgba(0,0,0,.6); animation: hmt-in .15s ease-out; }
#hmt-modal .m-title { font-size: 16px; font-weight: 700; color: #fff; }
#hmt-modal .m-text { margin-top: 6px; color: #a9a9c2; font-size: 13px; word-break: break-word; }
#hmt-modal .m-act { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
#hmt-modal button { all: unset; box-sizing: border-box; cursor: pointer; padding: 8px 16px; border-radius: 8px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 13px; }
#hmt-modal button:hover { background: #3a3a52; color: #fff; }
#hmt-modal button.pri { background: var(--hm-ac, #5b7cff); color: #fff; }
#hmt-modal button.danger { background: #e5484d; color: #fff; }
/* kolom search diperpanjang */
html.hm-search .header-table { width: min(var(--hm-sw, 520px), 100%) !important; max-width: none !important; }
html.hm-search .header-table .search-input, html.hm-search #search { flex: 1 1 auto; }
html.hm-search #query-input { width: 100% !important; }
/* pilihan warna aksen */
#hmt-panel .hmt-acc { flex: none; display: flex; align-items: center; gap: 9px; }
#hmt-panel .hmt-dot { width: 22px; height: 22px; border-radius: 50%; border: 2px solid transparent; box-shadow: 0 0 0 1px rgba(0,0,0,.5); transition: transform .12s; }
#hmt-panel .hmt-dot:hover { transform: scale(1.15); }
#hmt-panel .hmt-dot.on { border-color: #fff; }
#hmt-panel input.hmt-color { width: 30px; height: 26px; padding: 0; margin: 0; border: 0; background: none; cursor: pointer; }
/* penanda sudah diunduh */
.hm-done { position: absolute; left: 6px; top: 27px; padding: 3px 6px; border-radius: 4px; pointer-events: none;
background: var(--hm-ac, #5b7cff); color: #fff; font: 700 10px/1 sans-serif; box-shadow: 0 1px 4px rgba(0,0,0,.5); }
.hm-card.is-done img { filter: saturate(.55) brightness(.78); }
/* ---------- FILTER TAG ---------- */
#hmt-tf { position: fixed; top: 56px; right: 12px; bottom: 12px; width: min(340px, calc(100vw - 24px)); z-index: 99990; display: none; flex-direction: column; overflow: hidden;
background: #1a1a24; color: #e6e6f0; border: 1px solid #33334a; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,.6);
font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; }
#hmt-tf.open { display: flex; }
#hmt-tf *, #hmt-tf *::before, #hmt-tf *::after { box-sizing: border-box; }
#hmt-tf button { all: unset; box-sizing: border-box; cursor: pointer; }
#hmt-tf .tf-head { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px 12px 16px; }
#hmt-tf .tf-title { flex: 1; font-size: 15px; font-weight: 700; color: #fff; }
#hmt-tf .tf-close { width: 28px; height: 28px; border-radius: 8px; text-align: center; line-height: 28px; font-size: 16px; color: #9a9ab5; }
#hmt-tf .tf-close:hover { background: #2a2a3b; color: #fff; }
#hmt-tf .tf-top { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 0 14px 12px; border-bottom: 1px solid #2b2b3b; }
#hmt-tf .tf-seg { display: flex; gap: 4px; padding: 3px; border-radius: 11px; background: #12121a; }
#hmt-tf .tf-seg button { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 0; border-radius: 8px; color: #8e8ea8; font-weight: 600; font-size: 12.5px; }
#hmt-tf .tf-seg button:hover { color: #fff; }
#hmt-tf .tf-seg button i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .85; }
#hmt-tf .tf-seg button[data-v="inc"].on { background: #1f4033; color: #a8f0cc; }
#hmt-tf .tf-seg button[data-v="exc"].on { background: #4a2530; color: #ffb3c0; }
#hmt-tf .tf-search { position: relative; }
#hmt-tf input.tf-in { width: 100%; height: 34px; padding: 0 30px 0 12px; border-radius: 10px; background: #12121a !important; color: #eee !important; border: 1px solid #2f2f45 !important; outline: none; font: 13px system-ui, sans-serif; }
#hmt-tf input.tf-in:focus { border-color: #5b5f8a !important; }
#hmt-tf .tf-x { display: none; position: absolute; right: 4px; top: 5px; width: 24px; height: 24px; border-radius: 6px; text-align: center; line-height: 24px; color: #8e8ea8; font-size: 13px; }
#hmt-tf .tf-x.on { display: block; }
#hmt-tf .tf-x:hover { background: #2a2a3b; color: #fff; }
#hmt-tf .tf-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 14px; scrollbar-width: thin; scrollbar-color: #3a3a50 transparent; }
#hmt-tf .tf-hint { padding: 10px 12px; border-radius: 10px; background: #12121a; color: #8e8ea8; font-size: 12px; margin-bottom: 14px; }
#hmt-tf .tf-active { display: flex; flex-direction: column; gap: 10px; padding: 12px; margin-bottom: 14px; border-radius: 12px; background: #12121a; }
#hmt-tf .tf-code { padding: 7px 9px; border-radius: 8px; background: #1a1a24; color: #b9c4ff; font: 12px/1.4 ui-monospace, Consolas, monospace; word-break: break-all; user-select: all; }
#hmt-tf .tf-lab { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #7f86b8; }
#hmt-tf .tf-lab em { font-style: normal; font-weight: 500; letter-spacing: 0; text-transform: none; color: #6c6c88; }
#hmt-tf .tf-chips { display: flex; flex-wrap: wrap; gap: 6px; }
#hmt-tf .tf-empty { color: #6c6c88; font-size: 12px; padding: 4px 0; }
#hmt-tf .tf-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 8px; background: #262636; color: #d3d5ee; font-size: 12px; font-weight: 600; border: 1px solid transparent; transition: background .12s; }
#hmt-tf .tf-chip:hover { background: #313147; }
#hmt-tf .tf-chip small { color: #7d7d99; font-weight: 500; font-size: 11px; }
#hmt-tf .tf-chip.hide { background: #4a2530; color: #ffb3c0; border-color: #6e3040; }
#hmt-tf .tf-chip.hide:hover { background: #5a2d3a; }
#hmt-tf .tf-chip.req { background: #1f4033; color: #a8f0cc; border-color: #2b5f49; }
#hmt-tf .tf-chip.req:hover { background: #274d3e; }
#hmt-tf .tf-chip.hide small, #hmt-tf .tf-chip.req small { color: inherit; opacity: .7; }
#hmt-tf .tf-ns { font-size: 11px; font-weight: 700; opacity: .9; }
#hmt-tf .tf-ns.f { color: #ff9ec4; }
#hmt-tf .tf-ns.m { color: #8fc2ff; }
#hmt-tf .tf-ns.o { color: #c9b27a; font-weight: 600; }
#hmt-tf .tf-chip.hide .tf-ns, #hmt-tf .tf-chip.req .tf-ns { color: inherit; }
#hmt-tf .tf-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid #2b2b3b; }
#hmt-tf .tf-clear { padding: 8px 12px; border-radius: 9px; background: #262636; color: #d3d5ee; font-weight: 600; font-size: 12.5px; white-space: nowrap; }
#hmt-tf .tf-clear:hover { background: #34344c; color: #fff; }
#hmt-tf .tf-go { flex: 1; display: block; text-align: center; padding: 9px 12px; border-radius: 9px; background: #3fa77a; color: #fff; font-weight: 700; font-size: 13.5px; }
#hmt-tf .tf-go:hover { background: #48b988; }
#hmt-tf .tf-go.dis { background: #2a2a3b; color: #6c6c88; cursor: default; }
`;
const style = document.createElement('style');
style.id = 'hmt-style';
style.textContent = css;
// Style dipasang sedini mungkin (document-start) dan dipasang ulang kalau situs membuangnya.
function ensureStyle() {
if (!style.isConnected) (document.head || root).appendChild(style);
}
/* ====================================================================
* Apply state (idempoten: tidak mengubah DOM kalau sudah sesuai)
* ==================================================================== */
function mixHex(a, b, t) { // campur warna a dengan b sebesar t (0..1)
const n = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const A = n(a), B = n(b);
return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join('');
}
function setVar(name, val) {
if (root.style.getPropertyValue(name) !== val) root.style.setProperty(name, val);
}
function apply() {
ensureStyle();
root.classList.toggle('hm-dark', !!state.dark);
root.setAttribute('data-hmt-tp', state.toastPos || 'br');
root.classList.toggle('hm-grid', !!(state.grid && !isReader));
root.classList.toggle('hm-dt', state.cardStyle === 'detail');
root.classList.toggle('hm-trim', !isReader && !trimmed);
const tOn = !!(state.thumbs && isGallery && thumbHost && thumbHost.isConnected && thumbOrig && thumbOrig.isConnected);
root.classList.toggle('hm-thumbs-on', tOn);
if (thumbOrig) thumbOrig.classList.toggle('hmt-hide', tOn);
setVar('--hm-tw', state.thumbW + 'px');
root.classList.toggle('hm-webtoon', !!(state.webtoon && isReader && !wtFailed));
root.classList.toggle('hm-search', !!state.searchOn);
root.classList.toggle('hm-sticky', !!state.stickyNav);
root.classList.toggle('hmt-ready', stkReady || !state.stickyNav || isReader);
stickyNavSync();
setVar('--hm-sw', state.searchW + 'px');
setVar('--hm-card', state.cardW + 'px');
const ac = /^#[0-9a-f]{6}$/i.test(state.accent) ? state.accent : '#5b7cff';
setVar('--hm-ac', ac); setVar('--hm-ac2', mixHex(ac, '#ffffff', 0.18));
setVar('--hm-act', mixHex(ac, '#ffffff', 0.6)); setVar('--hm-acbg', mixHex(ac, '#1b1b25', 0.72));
setVar('--hm-rw', state.readerW ? state.readerW + 'px' : '100%');
}
// Banner judul: selector-nya diingat dari kunjungan sebelumnya, supaya sudah polos/gelap sejak awal (tanpa kedip)
(function injectBannerPre() {
if (isReader) return;
const sel = get('bannerSel', '');
if (!sel || !/^[#.\w\-\\:\s>]+$/.test(sel)) return;
const st = document.createElement('style');
st.id = 'hmt-style-pre';
st.textContent = 'html.hm-dark ' + sel + ' { background-color: #14141a !important; background-image: none !important; }\n' +
'html.hm-sticky:not(.hmt-ready) ' + sel + ' { visibility: hidden !important; }';
root.appendChild(st);
})();
setTimeout(() => { stkReady = true; root.classList.add('hmt-ready'); }, 1500); // jaga-jaga: jangan sampai tersembunyi terus
apply(); // langsung, sebelum halaman sempat tergambar -> tidak ada "kedip" tampilan asli
// Self-heal: kalau situs menimpa class/style di , pulihkan segera.
new MutationObserver(apply).observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
/* ====================================================================
* Header tetap di atas (fixed + spacer; tidak bergantung struktur induk)
* ==================================================================== */
// Navbar + banner judul ("Recently Added") ditumpuk tetap di atas. Tidak ada toggle class saat layout -> tidak kedip.
function stickyNavOff() {
if (navRO) { navRO.disconnect(); navRO = null; }
if (navAttrMO) { navAttrMO.disconnect(); navAttrMO = null; }
stk.forEach((it) => {
it.el.classList.remove('hmt-nav-fixed');
it.el.style.left = it.el.style.width = it.el.style.top = '';
it.sp.remove();
});
stk = [];
}
function stickyNavLayout() {
if (stkRaf) return;
stkRaf = requestAnimationFrame(() => { stkRaf = 0; stickyNavLayoutNow(); });
}
function stickyNavLayoutNow() {
{
let top = stk.length ? stk[0].mt : 0;
for (const it of stk) {
if (!it.el.isConnected || !it.sp.isConnected) continue;
const h = it.el.offsetHeight;
const r = it.sp.getBoundingClientRect();
const sh = (h + it.mt + it.mb) + 'px';
if (it.sp.style.height !== sh) it.sp.style.height = sh;
const L = r.left + 'px', W = r.width + 'px', T = top + 'px';
if (it.el.style.left !== L) it.el.style.left = L;
if (it.el.style.width !== W) it.el.style.width = W;
if (it.el.style.top !== T) it.el.style.top = T;
top += h;
}
}
}
function findBanner() {
const sels = [...document.querySelectorAll('select')].filter((x) => !x.closest('.navbar, #hmt-overlay, #hmt-dl, #hmt-tf, #hmt-panel, .hm-grid-wrap, .gallery-content'));
for (const sel of sels) {
for (let e = sel.parentElement; e && e !== document.body && !e.classList.contains('container'); e = e.parentElement) {
if (e.querySelector('.hm-grid-wrap, .gallery-content, .navbar')) break;
if (e.querySelector('h1, h2, h3') && e.offsetHeight < 160) return e;
}
}
return null;
}
function stickyNavSync() {
try { stickyNavSyncImpl(); } catch (e) { console.warn('[HMT] sticky header gagal', e); }
}
function stickyNavSyncImpl() {
if (isReader || !document.body) return;
if (!state.stickyNav) { stickyNavOff(); return; }
if (stk.length && stk.every((it) => it.el.isConnected && it.sp.isConnected)) {
if (stk.length > 1) return; // navbar + banner sudah terkunci
const now = Date.now();
if (now - stkBanTry < 400) return; // banner belum ada: cek paling sering tiap 0,4 dtk
stkBanTry = now;
if (!findBanner()) return;
}
stickyNavOff();
const bannerEl = findBanner();
if (bannerEl) {
let sel = '';
if (bannerEl.id) sel = '#' + bannerEl.id.replace(/[^\w-]/g, '');
else {
const cl = [...bannerEl.classList].filter((c) => !/^(hmt-|hm-)/.test(c) && /^[\w-]+$/.test(c)).slice(0, 3);
if (cl.length) sel = bannerEl.tagName.toLowerCase() + '.' + cl.join('.');
}
if (sel && get('bannerSel', '') !== sel) set('bannerSel', sel);
}
const els = [document.querySelector('.navbar-static-top, .navbar'), bannerEl].filter(Boolean);
if (!els.length) return;
els.forEach((el) => {
const cs = getComputedStyle(el);
const sp = document.createElement('div');
sp.className = 'hmt-nav-spacer';
if (el !== els[0]) {
el.classList.add('hmt-banner');
// tandai elemen yang memuat tulisan judul (apa pun tag-nya), supaya tidak terpotong
const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n;
while ((n = tw.nextNode())) {
if (!n.nodeValue.trim() || n.parentElement.closest('select, option, script, style')) continue;
n.parentElement.classList.add('hmt-bt');
break;
}
}
sp.style.cssText = 'width:100%;pointer-events:none;';
sp.style.height = el.offsetHeight + 'px';
el.before(sp);
stk.push({ el, sp, mt: parseFloat(cs.marginTop) || 0, mb: parseFloat(cs.marginBottom) || 0 });
});
stickyNavLayoutNow(); // posisi/ukuran dihitung dulu (sinkron), baru di-fixed -> tidak ada frame "loncat"
stk.forEach((it) => it.el.classList.add('hmt-nav-fixed'));
stickyNavLayoutNow();
stkReady = true; root.classList.add('hmt-ready');
// kalau skrip situs menimpa class/style elemen ini, pulihkan seketika (sebelum paint) -> tidak kedip
navAttrMO = new MutationObserver(() => {
let fix = false;
stk.forEach((it, i) => {
if (!it.el.classList.contains('hmt-nav-fixed')) { it.el.classList.add('hmt-nav-fixed'); fix = true; }
if (i > 0 && !it.el.classList.contains('hmt-banner')) it.el.classList.add('hmt-banner');
});
if (fix) stickyNavLayoutNow();
});
stk.forEach((it) => navAttrMO.observe(it.el, { attributes: true, attributeFilter: ['class', 'style'] }));
if (window.ResizeObserver) { navRO = new ResizeObserver(stickyNavLayout); stk.forEach((it) => { navRO.observe(it.sp); navRO.observe(it.el); }); }
}
window.addEventListener('resize', stickyNavLayout);
onReady(() => { stickyNavSync(); setTimeout(stickyNavSync, 600); setInterval(stickyNavSync, 2000); });
// gaya animasi: kf = keyframe, ease = easing, d = pengali durasi
const INTRO_FX = {
rise: { label: 'Naik', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateY(28px) scale(.95)' }, { opacity: 1, transform: 'none' }] },
drop: { label: 'Turun', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateY(-28px) scale(.95)' }, { opacity: 1, transform: 'none' }] },
slide: { label: 'Geser dari kiri', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateX(-56px)' }, { opacity: 1, transform: 'none' }] },
pop: { label: 'Pop (membal)', ease: 'ease-out', d: 1.1, kf: [{ opacity: 0, transform: 'scale(.55)' }, { opacity: 1, transform: 'scale(1.06)', offset: .6 }, { opacity: 1, transform: 'none' }] },
flip: { label: 'Balik 3D', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.15,
kf: [{ opacity: 0, transform: 'perspective(700px) rotateX(-75deg) translateY(24px)', transformOrigin: '50% 0%' }, { opacity: 1, transform: 'perspective(700px) rotateX(0deg) translateY(0)', transformOrigin: '50% 0%' }] },
blur: { label: 'Kabur ke jelas', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, filter: 'blur(14px)', transform: 'scale(1.08)' }, { opacity: 1, filter: 'blur(0px)', transform: 'none' }] },
wipe: { label: 'Tirai (kiri ke kanan)', ease: 'cubic-bezier(.65, 0, .35, 1)', kf: [{ clipPath: 'inset(0 100% 0 0 round 8px)' }, { clipPath: 'inset(0 0 0 0 round 8px)' }] },
fade: { label: 'Fade polos', ease: 'ease-out', kf: [{ opacity: 0 }, { opacity: 1 }] },
right: { label: 'Geser dari kanan', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateX(56px)' }, { opacity: 1, transform: 'none' }] },
diag: { label: 'Geser diagonal', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translate(-34px, 34px)' }, { opacity: 1, transform: 'none' }] },
zoomin: { label: 'Zoom mendekat', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.15, kf: [{ opacity: 0, transform: 'scale(1.3)' }, { opacity: 1, transform: 'none' }] },
bounce: { label: 'Memantul dari bawah', ease: 'linear', d: 1.35,
kf: [{ opacity: 0, transform: 'translateY(64px)', easing: 'cubic-bezier(.3, .9, .4, 1)' },
{ opacity: 1, transform: 'translateY(-14px)', offset: .55, easing: 'ease-in-out' },
{ opacity: 1, transform: 'translateY(5px)', offset: .78, easing: 'ease-in-out' },
{ opacity: 1, transform: 'none' }] },
swing: { label: 'Miring masuk', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'rotate(-9deg) translateY(30px)', transformOrigin: '0% 100%' }, { opacity: 1, transform: 'none', transformOrigin: '0% 100%' }] },
skew: { label: 'Geser miring', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.05, kf: [{ opacity: 0, transform: 'translateX(-46px) skewX(-14deg)' }, { opacity: 1, transform: 'none' }] },
spin: { label: 'Putar masuk', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, transform: 'rotate(-120deg) scale(.4)' }, { opacity: 1, transform: 'none' }] },
flipy: { label: 'Balik horizontal', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, transform: 'perspective(700px) rotateY(-80deg)', transformOrigin: '0% 50%' }, { opacity: 1, transform: 'perspective(700px) rotateY(0deg)', transformOrigin: '0% 50%' }] },
tilt: { label: 'Rebah 3D', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.15, kf: [{ opacity: 0, transform: 'perspective(700px) rotateX(70deg) translateY(30px)', transformOrigin: '50% 100%' }, { opacity: 1, transform: 'perspective(700px) rotateX(0deg) translateY(0)', transformOrigin: '50% 100%' }] },
iris: { label: 'Iris (lingkaran membuka)', ease: 'cubic-bezier(.65, 0, .35, 1)', d: 1.2, kf: [{ clipPath: 'circle(0% at 50% 50%)' }, { clipPath: 'circle(75% at 50% 50%)' }] },
wipeup: { label: 'Tirai (bawah ke atas)', ease: 'cubic-bezier(.65, 0, .35, 1)', kf: [{ clipPath: 'inset(100% 0 0 0 round 8px)' }, { clipPath: 'inset(0 0 0 0 round 8px)' }] },
};
const INTRO_STYLE_OPTS = Object.keys(INTRO_FX).map((k) => [k, INTRO_FX[k].label]).concat([['mix', 'Campur (acak tiap kartu)']]);
const INTRO = { dur: 750, maxStagger: 1800, settle: 250, maxWait: 3500 };
// gaya animasi header (navbar / banner judul). 'items' = tiap bagian (logo, menu, search) muncul satu per satu
const HEAD_FX = {
none: { label: 'Tanpa animasi' },
slide: { label: 'Geser dari atas', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'translateY(-100%)' }, { opacity: 1, transform: 'none' }] },
fade: { label: 'Fade polos', ease: 'ease-out', d: 1.2, kf: [{ opacity: 0 }, { opacity: 1 }] },
wipe: { label: 'Tirai (kiri ke kanan)', ease: 'cubic-bezier(.65, 0, .35, 1)', d: 1.3, kf: [{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0 0 0)' }] },
unfold: { label: 'Buka dari atas', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'scaleY(0)', transformOrigin: '50% 0%' }, { opacity: 1, transform: 'scaleY(1)', transformOrigin: '50% 0%' }] },
blur: { label: 'Kabur ke jelas', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, filter: 'blur(12px)' }, { opacity: 1, filter: 'blur(0px)' }] },
zoom: { label: 'Zoom masuk', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'scale(.9)' }, { opacity: 1, transform: 'none' }] },
left: { label: 'Geser dari kiri', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'translateX(-60px)' }, { opacity: 1, transform: 'none' }] },
right: { label: 'Geser dari kanan', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'translateX(60px)' }, { opacity: 1, transform: 'none' }] },
bounce: { label: 'Turun memantul', ease: 'linear', d: 1.5,
kf: [{ opacity: 0, transform: 'translateY(-100%)', easing: 'cubic-bezier(.3, .9, .4, 1)' },
{ opacity: 1, transform: 'translateY(8px)', offset: .6, easing: 'ease-in-out' },
{ opacity: 1, transform: 'translateY(-3px)', offset: .82, easing: 'ease-in-out' },
{ opacity: 1, transform: 'none' }] },
flipx: { label: 'Balik 3D (dari atas)', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, transform: 'perspective(900px) rotateX(-90deg)', transformOrigin: '50% 0%' }, { opacity: 1, transform: 'perspective(900px) rotateX(0deg)', transformOrigin: '50% 0%' }] },
iris: { label: 'Iris (melebar dari tengah)', ease: 'cubic-bezier(.65, 0, .35, 1)', d: 1.3, kf: [{ clipPath: 'inset(0 50% 0 50%)' }, { clipPath: 'inset(0 0 0 0)' }] },
items: { label: 'Bagian satu per satu' },
};
const BLOCK_STYLE_OPTS = [['none', 'Tanpa animasi']].concat(Object.keys(INTRO_FX).map((k) => [k, INTRO_FX[k].label]));
const HEAD_STYLE_OPTS = Object.keys(HEAD_FX).map((k) => [k, HEAD_FX[k].label]);
/* ====================================================================
* Panel Pengaturan (menu "Pengaturan" di navbar atau Alt+S)
* ==================================================================== */
const DEFAULTS = { dark: true, grid: true, webtoon: true, cardW: 190, cardStyle: 'detail', readerW: 800,
thumbs: true, thumbReader: false, thumbW: 160, pvOn: true, pvSize: 80, pvDelay: 150, pvMode: 'side', dlPanel: true, dlFmt: 'orig', dlQ: 90, dlOut: 'zip', dlDest: 'dl', dlThumb: 150, accent: '#5b7cff', dlMark: true, searchOn: true, stickyNav: true, introOn: true, toastPos: 'br', introScroll: true, introScrollStyle: 'same', introBlock: 'rise', introNav: 'slide', introBanner: 'wipe', introStyle: 'rise', introOrder: 'seq', introSpeed: 1, introStep: 80, searchW: 520, romaji: true, romajiKanji: true, romajiLong: 'simple', tfInc: [], tfExc: [] };
let buildWebtoonFn = null; // diisi di bagian reader
const refreshers = [];
let overlay = null;
let panelReady = false;
function afterChange(key) {
if (key === 'webtoon' && state.webtoon && isReader && !window.__wtReady && buildWebtoonFn) {
wtFailed = false; apply(); buildWebtoonFn();
} else apply();
if (key === 'dark' && state.dark && !isReader) rescanDark();
if (key === 'thumbs' || key === 'thumbReader') rebuildThumbs();
if (key === 'pvOn') hidePreview();
if (key === 'dlMark') markCards();
if (key === 'romaji' || key === 'romajiKanji' || key === 'romajiLong') {
rmCache.clear();
if (kmState === 'error') kmState = 'idle';
if (state.romaji && state.romajiKanji) kmLoad();
romajiApply();
}
if (key === 'toastPos') toast('Notifikasi akan muncul di sini', { type: 'info', ms: 2500 });
if (key === 'introScroll') srRefresh();
else if (key !== 'introScrollStyle' && key.indexOf('intro') === 0) introPreview();
applyTagFilter();
if (tfUi && tfEl && tfEl.classList.contains('open')) renderTf();
}
function section(panel, title) {
panel.appendChild(mk('div', 'hmt-sec', T(title)));
panel._g = null; // baris berikutnya masuk ke kartu baru
}
function grpOf(panel) {
if (!panel._g) { panel._g = mk('div', 'hmt-grp'); panel.appendChild(panel._g); }
return panel._g;
}
function row(panel, label, desc, ctrl, col) {
const r = mk('div', 'hmt-row' + (col ? ' col' : ''));
const left = mk('div', 'hmt-txt');
left.appendChild(mk('div', 'hmt-lbl', T(label)));
if (desc) left.appendChild(mk('div', 'hmt-desc', T(desc)));
r.appendChild(left);
if (ctrl) r.appendChild(ctrl);
grpOf(panel).appendChild(r);
return r;
}
const refreshAll = () => refreshers.forEach((f) => f());
// baris diredupkan & dikunci selama saklar induknya mati
function dep(r, key) {
const f = () => r.classList.toggle('hmt-dis', !state[key]);
refreshers.push(f); f();
return r;
}
function addSwitch(panel, label, desc, key) {
const sw = mk('button', 'hmt-sw');
sw.type = 'button';
sw.setAttribute('role', 'switch');
sw.setAttribute('aria-label', T(label));
const refresh = () => sw.setAttribute('aria-checked', String(!!state[key]));
sw.addEventListener('click', () => { state[key] = !state[key]; set(key, state[key]); refreshAll(); afterChange(key); });
refreshers.push(refresh); refresh();
const r = row(panel, label, desc, sw);
r.classList.add('hmt-clk'); // klik di mana saja pada baris = ubah saklar
r.addEventListener('click', (e) => { if (!e.target.closest('button')) sw.click(); });
return r;
}
function addRange(panel, label, desc, key, min, max, step, unit) {
const box = mk('div', 'hmt-rangebox');
const input = document.createElement('input');
input.type = 'range'; input.className = 'hmt-range';
input.min = min; input.max = max; input.step = step;
const val = mk('span', 'hmt-val');
const refresh = () => { input.value = state[key]; val.textContent = state[key] + unit; };
input.addEventListener('input', () => { state[key] = +input.value; val.textContent = state[key] + unit; set(key, state[key]); apply(); if (key.indexOf('intro') === 0) introPreview(); });
refreshers.push(refresh); refresh();
box.append(input, val);
return row(panel, label, desc, box);
}
function addSeg(panel, label, desc, key, opts) {
const seg = mk('div', 'hmt-seg');
const btns = opts.map(([v, t]) => {
const b = mk('button', '', T(t)); b.type = 'button';
b.addEventListener('click', () => {
state[key] = v; set(key, v);
if (key === 'lang') { langChanged(); return; }
refresh(); apply(); if (key.indexOf('intro') === 0) introPreview();
});
seg.appendChild(b); return [v, b];
});
const refresh = () => btns.forEach(([v, b]) => b.classList.toggle('on', state[key] === v));
refreshers.push(refresh); refresh();
return row(panel, label, desc, seg);
}
function addSelect(panel, label, desc, key, opts) {
const sel = mk('select', 'hmt-sel');
opts.forEach(([v, t]) => { const o = document.createElement('option'); o.value = String(v); o.textContent = T(t); sel.appendChild(o); });
const refresh = () => { sel.value = String(state[key]); };
sel.addEventListener('change', () => { const o = opts.find(([v]) => String(v) === sel.value); state[key] = o ? o[0] : sel.value; set(key, state[key]); afterChange(key); });
refreshers.push(refresh); refresh();
return row(panel, label, desc, sel);
}
let curTab = 'tampilan';
function addAccent(panel) {
const box = mk('div', 'hmt-acc');
const presets = ['#5b7cff', '#a259ff', '#ff5d8f', '#ff8a3d', '#2ec4b6', '#3ecf8e'];
const setAc = (c) => { state.accent = c; set('accent', c); refresh(); apply(); };
const btns = presets.map((c) => {
const b = mk('button', 'hmt-dot'); b.type = 'button'; b.title = c; b.style.background = c;
b.addEventListener('click', () => setAc(c)); box.appendChild(b); return [c, b];
});
const inp = document.createElement('input'); inp.type = 'color'; inp.className = 'hmt-color'; inp.title = T('Warna kustom');
inp.addEventListener('input', () => setAc(inp.value));
box.appendChild(inp);
const refresh = () => {
btns.forEach(([c, b]) => b.classList.toggle('on', c === String(state.accent).toLowerCase()));
inp.value = /^#[0-9a-f]{6}$/i.test(state.accent) ? state.accent : '#5b7cff';
};
refreshers.push(refresh); refresh();
return row(panel, 'Warna aksen', 'Warna tombol, slider, dan penanda', box);
}
// Ganti bahasa: bangun ulang semua UI yang sudah terlanjur dibuat supaya teksnya ikut berganti.
function langChanged() {
if (overlay) { overlay.remove(); overlay = null; }
buildPanel();
overlay.classList.add('hmt-still', 'open'); // tanpa animasi buka, supaya tidak berkedip
refreshAll();
const a = document.querySelector('#hmt-nav a'); if (a) a.textContent = T('⚙ Pengaturan');
const a2 = document.querySelector('#hmt-tf-nav a'); if (a2) a2.title = T('Filter Tag (Alt+T)');
const fab = document.getElementById('hmt-fab'); if (fab) fab.title = T('Pengaturan (Alt+S)');
if (tfEl) { const was = tfEl.classList.contains('open'); tfEl.remove(); tfEl = null; tfUi = null; if (was) openTf(); }
if (dlEl) closeDl();
if (dmEl) { dmEl.remove(); dmEl = null; dmBody = null; dmTitle = null; dmPlaced = false; dmRows.clear(); dmTick(); }
document.querySelectorAll('.hm-done').forEach((b) => b.remove());
markCards();
}
function buildPanel() {
refreshers.length = 0;
overlay = mk('div'); overlay.id = 'hmt-overlay';
const panel = mk('div'); panel.id = 'hmt-panel';
panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', T('Pengaturan'));
overlay.appendChild(panel);
const head = mk('div', 'hmt-head');
const ttl = mk('div', 'hmt-ttl');
ttl.appendChild(mk('div', 'hmt-title', T('Pengaturan')));
const sub = mk('div', 'hmt-sub');
sub.append(mk('b', '', AUTHOR), document.createTextNode(' · v' + VERSION));
ttl.appendChild(sub);
const close = mk('button', 'hmt-close', '✕'); close.type = 'button'; close.title = T('Tutup (Esc)');
close.addEventListener('click', closePanel);
head.append(mk('div', 'hmt-logo', '⚙'), ttl, close);
const body = mk('div', 'hmt-body');
const nav = mk('div', 'hmt-nav');
const tabs = [];
const addTab = (id, icon, label) => {
const btn = mk('button', 'hmt-tab'); btn.type = 'button';
btn.append(mk('span', 'hmt-ic', icon), mk('span', 'hmt-tl', T(label)));
const pane = mk('div', 'hmt-pane');
btn.addEventListener('click', () => { curTab = id; tabs.forEach((t) => { t.btn.classList.toggle('on', t.id === id); t.pane.classList.toggle('on', t.id === id); t.pane.scrollTop = 0; }); });
tabs.push({ id, btn, pane });
nav.appendChild(btn);
return pane;
};
/* ---- Tampilan ---- */
const pView = addTab('tampilan', '🎨', 'Tampilan');
section(pView, 'Umum');
addSeg(pView, 'Bahasa / Language', 'Bahasa antarmuka panel dan notifikasi', 'lang', [['id', 'Indonesia'], ['en', 'English']]);
addSwitch(pView, 'Mode gelap', 'Tema gelap untuk seluruh situs', 'dark');
addAccent(pView);
addSelect(pView, 'Posisi notifikasi', 'Tempat muncul pesan seperti "Kamus kanji siap"', 'toastPos',
[['br', 'Kanan bawah'], ['bl', 'Kiri bawah'], ['bc', 'Tengah bawah'], ['tr', 'Kanan atas'], ['tl', 'Kiri atas'], ['tc', 'Tengah atas']]);
section(pView, 'Header');
addSwitch(pView, 'Header tetap di atas', 'Navbar (menu & pencarian) tidak hilang saat halaman di-scroll', 'stickyNav');
addSwitch(pView, 'Perpanjang kolom search', 'Kolom pencarian di header dibuat lebih panjang', 'searchOn');
dep(addRange(pView, 'Lebar kolom search', 'Lebar kolom pencarian (px)', 'searchW', 240, 900, 20, 'px'), 'searchOn');
if (!isReader) {
section(pView, 'Daftar galeri');
addSwitch(pView, 'Poster grid', 'Tampilkan galeri sebagai kartu poster', 'grid');
dep(addSeg(pView, 'Gaya kartu', 'Detail: cover + tabel info seperti halaman galeri. Poster: cover penuh', 'cardStyle', [['detail', 'Detail'], ['poster', 'Poster']]), 'grid');
dep(addRange(pView, 'Ukuran kartu', 'Lebar minimum tiap kartu poster', 'cardW', 110, 400, 10, 'px'), 'grid');
} else {
section(pView, 'Reader');
addSwitch(pView, 'Mode webtoon', 'Scroll vertikal, semua halaman berurutan', 'webtoon');
dep(addSeg(pView, 'Lebar gambar', 'Lebar halaman pada mode webtoon (px)', 'readerW',
[[600, '600'], [800, '800'], [1000, '1000'], [0, 'Penuh']]), 'webtoon');
}
/* ---- Animasi ---- */
if (!isReader) {
const pAnim = addTab('animasi', '🎬', 'Animasi');
section(pAnim, 'Saat halaman dimuat');
addSwitch(pAnim, 'Animasi saat memuat', 'Header dan kartu muncul dengan animasi saat halaman dimuat', 'introOn');
section(pAnim, 'Header');
dep(addSelect(pAnim, 'Navbar (menu atas)', 'Efek masuk untuk logo, menu, dan kolom search', 'introNav', HEAD_STYLE_OPTS), 'introOn');
dep(addSelect(pAnim, 'Banner judul', 'Efek masuk untuk bar \"Recently Added\" (halaman daftar)', 'introBanner', HEAD_STYLE_OPTS), 'introOn');
section(pAnim, 'Halaman galeri');
dep(addSelect(pAnim, 'Cover & info', 'Efek masuk untuk cover, judul, dan tabel info saat membuka sebuah galeri', 'introBlock', BLOCK_STYLE_OPTS), 'introOn');
section(pAnim, 'Kartu & thumbnail');
dep(addSelect(pAnim, 'Gaya animasi', 'Pilih efek masuk tiap kartu. Perubahan langsung dipratinjau di halaman', 'introStyle', INTRO_STYLE_OPTS), 'introOn');
dep(addSelect(pAnim, 'Urutan muncul', 'Berurutan: kiri ke kanan lalu ke bawah. Diagonal: menyapu dari pojok kiri-atas. Acak: urutan dikocok', 'introOrder',
[['seq', 'Berurutan (kiri-atas)'], ['diag', 'Diagonal'], ['rand', 'Acak']]), 'introOn');
dep(addSeg(pAnim, 'Kecepatan', 'Durasi animasi tiap kartu', 'introSpeed', [[0.6, 'Cepat'], [1, 'Normal'], [1.7, 'Lambat']]), 'introOn');
dep(addRange(pAnim, 'Jeda antar kartu', 'Selisih waktu muncul antar kartu (ms)', 'introStep', 0, 300, 10, 'ms'), 'introOn');
const bPlay = mk('button', 'hmt-reset', T('Putar ulang')); bPlay.type = 'button';
bPlay.addEventListener('click', () => introPreview());
dep(row(pAnim, 'Pratinjau', 'Putar ulang animasi pada kartu yang sedang terlihat', bPlay), 'introOn');
section(pAnim, 'Saat di-scroll');
addSwitch(pAnim, 'Animasi saat di-scroll', 'Kartu dan thumbnail di bawah layar muncul dengan animasi ketika di-scroll sampai ke situ', 'introScroll');
dep(addSelect(pAnim, 'Gaya saat di-scroll', 'Efek masuk untuk kartu yang baru terlihat. Coba scroll halaman untuk melihatnya', 'introScrollStyle', [['same', 'Sama dengan gaya kartu']].concat(INTRO_STYLE_OPTS)), 'introScroll');
}
if (!isReader) {
/* ---- Thumbnail ---- */
const pThumb = addTab('thumb', '🖼', 'Thumbnail');
section(pThumb, 'Thumbnail galeri');
addSwitch(pThumb, 'Thumbnail besar', 'Ganti strip thumbnail dengan grid besar', 'thumbs');
dep(addSwitch(pThumb, 'Resolusi asli', 'Pakai gambar reader di thumbnail (berat). Mati = ringan', 'thumbReader'), 'thumbs');
dep(addRange(pThumb, 'Ukuran thumbnail', 'Lebar minimum tiap thumbnail', 'thumbW', 120, 420, 10, 'px'), 'thumbs');
/* ---- Romaji ---- */
const pRm = addTab('romaji', '🔤', 'Romaji');
section(pRm, 'Judul');
addSwitch(pRm, 'Judul ke romaji', 'Ubah tulisan Jepang (hiragana, katakana, kanji) di judul jadi huruf latin. Arahkan kursor ke kartu untuk melihat judul asli', 'romaji');
dep(addSwitch(pRm, 'Ubah kanji juga', 'Unduh kamus kanji sekali (±17 MB, lalu tersimpan di cache browser). Mati = hanya kana yang diubah', 'romajiKanji'), 'romaji');
dep(addSeg(pRm, 'Vokal panjang', 'Cara menulis vokal panjang: toukyou atau tokyo', 'romajiLong',
[['simple', 'Sederhana'], ['keep', 'Apa adanya']]), 'romaji');
/* ---- Hover preview ---- */
const pPrev = addTab('preview', '🔍', 'Hover preview');
section(pPrev, 'Pratinjau saat hover');
addSwitch(pPrev, 'Hover preview', 'Tampilkan halaman besar saat kursor di thumbnail', 'pvOn');
dep(addRange(pPrev, 'Ukuran preview', 'Tinggi preview (% tinggi layar)', 'pvSize', 30, 98, 2, '%'), 'pvOn');
dep(addRange(pPrev, 'Jeda muncul', 'Waktu tunggu sebelum preview muncul', 'pvDelay', 0, 800, 50, 'ms'), 'pvOn');
dep(addSeg(pPrev, 'Posisi preview', 'Di samping thumbnail atau mengikuti kursor', 'pvMode',
[['side', 'Samping'], ['cursor', 'Kursor']]), 'pvOn');
/* ---- Unduhan ---- */
const pDl = addTab('unduh', '⬇', 'Unduhan');
section(pDl, 'Panel unduhan');
addSwitch(pDl, 'Panel pilih unduhan', 'Klik Download → pilih gambar dulu; jalan di latar belakang antar halaman', 'dlPanel');
dep(addSeg(pDl, 'Hasil default', 'Bentuk file yang dihasilkan', 'dlOut',
[['zip', 'ZIP'], ['pdf', 'PDF']].concat(HAS_DIR ? [['img', 'Gambar']] : [])), 'dlPanel');
dep(addRange(pDl, 'Kualitas JPG/PDF', 'Dipakai untuk PDF atau konversi JPG', 'dlQ', 50, 100, 5, '%'), 'dlPanel');
dep(addRange(pDl, 'Ukuran thumbnail panel', 'Lebar thumbnail di panel pilih gambar', 'dlThumb', 90, 320, 10, 'px'), 'dlPanel');
section(pDl, 'Riwayat');
addSwitch(pDl, 'Tandai yang sudah diunduh', 'Badge "✓ Diunduh" dan kartu diredupkan di daftar galeri', 'dlMark');
const bClear = mk('button', 'hmt-reset'); bClear.type = 'button';
const cntRefresh = () => { bClear.textContent = T('Hapus daftar ({0})', Object.keys(doneMap()).length); };
refreshers.push(cntRefresh); cntRefresh();
bClear.addEventListener('click', async () => {
const n = Object.keys(doneMap()).length;
if (!n) { toast('Daftar sudah kosong'); return; }
const ok = await askConfirm({ title: 'Hapus daftar sudah diunduh?', text: T('{0} galeri akan kehilangan penanda. File hasil unduhan tidak ikut terhapus.', n), ok: 'Hapus', cancel: 'Batal', danger: true });
if (!ok) return;
set('dlDone', {}); markCards(); cntRefresh(); toast('Daftar penanda dihapus', { type: 'ok' });
});
row(pDl, 'Daftar sudah diunduh', 'Reset semua penanda "diunduh"', bClear);
}
/* ---- Lainnya ---- */
const pMisc = addTab('lainnya', '🛠', 'Lainnya');
const keys = (t) => { const w = mk('div'); w.appendChild(mk('kbd', '', t)); return w; };
section(pMisc, 'Pintasan');
row(pMisc, 'Buka/tutup pengaturan', null, keys('Alt+S'));
if (!isReader) row(pMisc, 'Buka/tutup Filter Tag', null, keys('Alt+T'));
section(pMisc, 'Diagnostik');
const dumpBtn = mk('button', 'hmt-reset', T('Salin dump')); dumpBtn.type = 'button';
dumpBtn.addEventListener('click', () => copyDump(dumpBtn));
row(pMisc, 'Dump latar terang', 'Salin daftar elemen berlatar putih (untuk dicek)', dumpBtn);
section(pMisc, 'Tentang');
const badge = mk('span', 'hmt-badge');
badge.append(mk('b', '', AUTHOR), document.createTextNode(' · v' + VERSION));
row(pMisc, 'Hitomi Tweak', 'Poster grid, mode gelap, reader webtoon, dan filter tag untuk hitomi.la', badge);
tabs.forEach((t) => body.appendChild(t.pane));
body.insertBefore(nav, body.firstChild);
const first = tabs.find((t) => t.id === curTab) || tabs[0];
curTab = first.id;
first.btn.classList.add('on'); first.pane.classList.add('on');
/* ---- kaki: pintasan + reset ---- */
const foot = mk('div', 'hmt-foot');
const hint = mk('span'); hint.append(T('Buka/tutup: '));
hint.appendChild(mk('kbd', '', 'Alt+S'));
const rst = mk('button', 'hmt-reset warn', T('Reset default')); rst.type = 'button';
rst.addEventListener('click', async () => {
const ok = await askConfirm({ title: 'Reset semua pengaturan?', text: 'Semua pengaturan dikembalikan ke nilai awal.', ok: 'Reset', cancel: 'Batal', danger: true });
if (!ok) return;
Object.keys(DEFAULTS).forEach((k) => { state[k] = DEFAULTS[k]; set(k, DEFAULTS[k]); }); // bahasa tidak ikut di-reset
refreshAll();
afterChange('webtoon');
toast('Pengaturan dikembalikan ke default', { type: 'ok' });
});
foot.append(hint, rst);
panel.append(head, body, foot);
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closePanel(); });
document.body.appendChild(overlay);
}
function openPanel() {
if (!overlay) buildPanel();
else if (!overlay.isConnected) document.body.appendChild(overlay); // situs sempat menghapus body
refreshers.forEach((f) => f());
overlay.classList.add('open');
}
function closePanel() { if (overlay) overlay.classList.remove('open'); }
function togglePanel() { if (overlay && overlay.classList.contains('open')) closePanel(); else openPanel(); }
function addNavEntry() {
if (document.getElementById('hmt-nav')) return true;
const ul = document.querySelector('.navbar-nav, .navbar .nav, .navbar ul');
if (!ul) return false;
const li = mk('li'); li.id = 'hmt-nav';
const a = mk('a', '', T('⚙ Pengaturan')); a.href = '#';
a.addEventListener('click', (e) => { e.preventDefault(); togglePanel(); });
li.appendChild(a); ul.appendChild(li);
if (!isReader && !document.getElementById('hmt-tf-nav')) {
const li2 = mk('li'); li2.id = 'hmt-tf-nav';
const a2 = mk('a', '', '🏷 Filter'); a2.href = '#'; a2.title = T('Filter Tag (Alt+T)');
a2.addEventListener('click', (e) => { e.preventDefault(); toggleTf(); });
li2.appendChild(a2); ul.appendChild(li2);
updateTfBadge();
}
const fab = document.getElementById('hmt-fab');
if (fab) fab.remove();
return true;
}
// Dipanggil berkala oleh observer: pasang ulang menu kalau navbar dirender ulang situs.
function ensureNav() {
if (panelReady && (!document.getElementById('hmt-nav') || (!isReader && !document.getElementById('hmt-tf-nav')))) {
const old = document.getElementById('hmt-nav'); if (old && !document.getElementById('hmt-tf-nav')) old.remove();
addNavEntry();
}
}
function initPanel() {
panelReady = true;
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay && overlay.classList.contains('open')) { e.stopImmediatePropagation(); closePanel(); return; }
if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyS') { e.preventDefault(); e.stopImmediatePropagation(); togglePanel(); }
}, true);
let tries = 0;
(function tick() {
if (addNavEntry()) return;
if (++tries < 12) return setTimeout(tick, 400);
// fallback kalau navbar tidak ketemu: satu tombol kecil
if (document.getElementById('hmt-fab')) return;
const fab = mk('button', '', '⚙'); fab.id = 'hmt-fab'; fab.title = T('Pengaturan (Alt+S)');
fab.addEventListener('click', togglePanel);
document.body.appendChild(fab);
})();
}
/* ====================================================================
* POSTER GRID (list + related galleries)
* Kartu asli situs dibaca, lalu dibangun ulang jadi kartu poster bersih.
* ==================================================================== */
const LANG = {
'english': 'EN', '日本語': 'JP', '中文': 'CN', 'bahasa indonesia': 'ID', '한국어': 'KR',
'español': 'ES', 'français': 'FR', 'deutsch': 'DE', 'русский': 'RU', 'italiano': 'IT',
'português': 'PT', 'tiếng việt': 'VI', 'ไทย': 'TH', 'polski': 'PL', 'türkçe': 'TR',
};
const TYPE = {
'doujinshi': 'DJ', 'manga': 'MG', 'artist cg': 'ACG', 'game cg': 'GCG', 'imageset': 'IS', 'anime': 'AN',
};
function cardSrc(card) {
const imgEl = card.querySelector('.dj-img1 img') || card.querySelector('img');
let src = '';
if (imgEl) src = imgEl.getAttribute('data-src') || imgEl.getAttribute('src') || '';
if (!src) {
const s = card.querySelector('.dj-img1 source[data-srcset], source[data-srcset]');
if (s) src = (s.getAttribute('data-srcset') || '').trim().split(/\s+/)[0];
}
if (src.startsWith('//')) src = 'https:' + src;
return src;
}
function parseCard(card) {
const a = card.querySelector('a.lillie') || card.querySelector('a[href]');
if (!a) return null;
const rows = {};
card.querySelectorAll('table.dj-desc tr').forEach((tr) => {
const tds = tr.querySelectorAll('td');
if (tds.length >= 2) rows[txt(tds[0]).toLowerCase()] = tds[1];
});
const list = (td) => (td ? [...td.querySelectorAll('a')].map(txt).filter((t) => t && t !== '...') : []);
const dateM = card.textContent.match(/[A-Z][a-z]{2} \d{1,2}, \d{4}(?:, \d{1,2}:\d{2} [AP]M)?/);
return {
href: a.href,
src: cardSrc(card),
title: txt(card.querySelector('h1.lillie')) || txt(a),
artists: [...card.querySelectorAll('.artist-list a')].map(txt),
series: list(rows.series).join(', '),
group: list(rows.group || rows.groups).join(', '),
chars: list(rows.characters || rows.character).join(', '),
type: txt(rows.type),
lang: txt(rows.language),
tags: list(rows.tags),
tagKeys: rows.tags ? [...rows.tags.querySelectorAll('a')].map(tfKeyFromAnchor).filter(Boolean) : [],
date: dateM ? dateM[0] : '',
};
}
/* ---------- penanda "sudah diunduh" ---------- */
const gidOfHref = (h) => { const m = String(h || '').match(/(\d+)\.html(?:[#?].*)?$/); return m ? m[1] : ''; };
const doneMap = () => { const m = get('dlDone', {}); return m && typeof m === 'object' ? m : {}; };
function decorateDone(a, m) {
const id = a.dataset.gid || (a.dataset.gid = gidOfHref(a.getAttribute('href')));
const on = !!(state.dlMark && id && m[id]);
a.classList.toggle('is-done', on);
const b = a.querySelector('.hm-done');
if (on && !b) a.appendChild(mk('div', 'hm-done', T('✓ Diunduh')));
else if (!on && b) b.remove();
}
function markCards() { const m = doneMap(); document.querySelectorAll('.hm-card').forEach((a) => decorateDone(a, m)); }
function markDone(id) { if (!id) return; const m = doneMap(); m[id] = Date.now(); set('dlDone', m); markCards(); }
function unmarkDone(id) { const m = doneMap(); delete m[id]; set('dlDone', m); markCards(); }
function makeCard(d) {
const a = mk('a', 'hm-card');
a.href = d.href;
a.title = [d.title, d.artists.join(', '), d.series, d.type, d.lang, d.date].filter(Boolean).join('\n');
a.dataset.tfk = (d.tagKeys || []).join('|');
if (d.type) a.dataset.type = d.type.toLowerCase().replace(/[^a-z]/g, ''); // 'Artist CG' -> 'artistcg'
const cover = mk('div', 'hm-cover');
const img = document.createElement('img');
img.loading = 'lazy'; img.decoding = 'async'; img.alt = '';
if (d.src) img.src = d.src;
img.addEventListener('error', () => { img.style.visibility = 'hidden'; });
cover.appendChild(img);
const tags = mk('div', 'hm-tags');
d.tags.slice(0, 24).forEach((t) => tags.appendChild(mk('span', '', t)));
cover.appendChild(tags);
const badges = mk('div', 'hm-badges');
const langCode = LANG[d.lang.toLowerCase()] || (d.lang && d.lang.toLowerCase() !== 'n/a' ? d.lang.slice(0, 2).toUpperCase() : '');
badges.appendChild(mk('span', 'hm-badge', langCode));
badges.appendChild(mk('span', 'hm-badge', TYPE[d.type.toLowerCase()] || d.type.slice(0, 3).toUpperCase()));
if (!langCode) badges.firstChild.style.visibility = 'hidden';
cover.appendChild(badges);
const ov = mk('div', 'hm-ov');
ov.appendChild(mk('div', 'hm-title', d.title));
const sub = [d.artists.join(', '), d.series].filter(Boolean).join(' · ');
if (sub) ov.appendChild(mk('div', 'hm-sub', sub));
cover.appendChild(ov);
a.appendChild(cover);
// panel info (dipakai di gaya "detail")
const info = mk('div', 'hm-info');
info.appendChild(mk('div', 'hm-it', d.title));
if (d.artists.length) info.appendChild(mk('div', 'hm-ia', d.artists.join(', ')));
const rows = mk('div', 'hm-rows');
const addRow = (k, v, cls) => { if (!v) return; rows.append(mk('div', 'hm-k', k), mk('div', 'hm-v' + (cls ? ' ' + cls : ''), v)); };
addRow('Group', d.group);
addRow('Type', d.type, 'hm-vtype');
addRow('Language', d.lang);
addRow('Series', d.series);
addRow('Characters', d.chars);
if (d.tags.length) {
const chips = mk('div', 'hm-chips');
d.tags.forEach((t) => chips.appendChild(mk('span', '', t)));
rows.append(mk('div', 'hm-k', 'Tags'), chips);
}
info.appendChild(rows);
a.appendChild(info);
decorateDone(a, doneMap());
return a;
}
// Kartu di-cache per URL: selama galeri masih ada di daftar, elemen kartunya dipakai ulang
// (gambar tidak dimuat ulang), jadi tidak ada kedip saat situs merender ulang daftar.
const cardCache = new Map(); // href -> { el, sig }
const boxState = new WeakMap(); // .gallery-content -> { wrap, emptySince }
const EMPTY_GRACE = 1500; // tahan grid lama sebentar saat situs mengosongkan daftar
const hrefOf = (c) => {
const a = c.querySelector('a.lillie') || c.querySelector('a[href]');
return a ? a.href : '';
};
const cardSig = (d) => [d.title, d.artists.join(','), d.series, d.group, d.chars, d.type, d.lang, d.tags.join(','), d.date].join('\u0001');
function stateOf(box) {
let s = boxState.get(box);
if (!s) { s = { wrap: mk('div', 'hm-grid-wrap'), emptySince: 0 }; boxState.set(box, s); }
return s;
}
function buildGrid(box) {
const s = stateOf(box);
const wrap = s.wrap;
if (wrap.previousElementSibling !== box) box.after(wrap);
const cards = [...box.children].filter((c) => c.nodeType === 1);
if (!cards.length) {
if (!wrap.childElementCount) return;
if (!s.emptySince) { s.emptySince = Date.now(); setTimeout(gridify, EMPTY_GRACE + 50); return; }
if (Date.now() - s.emptySince < EMPTY_GRACE) return;
wrap.replaceChildren();
s.emptySince = 0;
return;
}
s.emptySince = 0;
const wanted = [];
const seen = new Set();
for (const c of cards) {
const href = hrefOf(c);
if (!href || seen.has(href)) continue;
seen.add(href);
let d;
try { d = parseCard(c); } catch (e) { console.warn('[HMT] gagal parse kartu', e, c); continue; }
if (!d) continue;
const sig = cardSig(d);
let entry = cardCache.get(href);
if (!entry || entry.sig !== sig) {
const el = makeCard(d);
if (entry) { // data berubah: pertahankan
lama yang sudah termuat
const oldImg = entry.el.querySelector('img');
const newImg = el.querySelector('img');
if (oldImg && newImg && oldImg.getAttribute('src')) newImg.replaceWith(oldImg);
}
entry = { el, sig };
cardCache.set(href, entry);
} else {
const img = entry.el.querySelector('img');
if (img && !img.getAttribute('src') && d.src) { img.style.visibility = ''; img.src = d.src; }
}
wanted.push(entry.el);
}
if (!wanted.length) return;
// reconcile: hanya ubah DOM kalau memang berbeda
const cur = wrap.children;
let same = cur.length === wanted.length;
for (let i = 0; same && i < wanted.length; i++) if (cur[i] !== wanted[i]) same = false;
if (same) return;
const keep = new Set(wanted);
for (const ch of [...cur]) if (!keep.has(ch)) ch.remove();
wanted.forEach((el, i) => {
if (wrap.children[i] !== el) wrap.insertBefore(el, wrap.children[i] || null);
});
if (cardCache.size > 600) {
for (const [k, v] of cardCache) if (!keep.has(v.el)) cardCache.delete(k);
}
}
function gridify() {
document.querySelectorAll('.gallery-content').forEach(buildGrid);
// buang grid yatim kalau container aslinya diganti oleh situs (kartu sudah dipindah lewat cache)
document.querySelectorAll('.hm-grid-wrap').forEach((w) => {
const p = w.previousElementSibling;
if (!p || !p.classList.contains('gallery-content')) w.remove();
});
applyTagFilter();
romajiApply();
}
/* ====================================================================
* FILTER TAG (panel samping, Alt+T)
* Pembuat query pencarian: pilih tag (sertakan / kecualikan), lalu tombol Cari mengisi
* kolom search situs dan menjalankannya, jadi hasilnya dari SELURUH galeri (bukan hanya halaman ini).
* ==================================================================== */
const tfNorm = (t) => String(t || '').replace(/[\u2640\u2642]/g, '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
const tfSplit = (k) => { const i = k.indexOf(':'); return i < 0 ? ['tag', k] : [k.slice(0, i), k.slice(i + 1)]; };
// kunci tag lengkap dengan namespace, mis. "female:big breasts"; tag tanpa namespace -> "tag:..."
function tfKeyFromAnchor(a) {
const t = txt(a);
if (!t || t === '...') return '';
const m = (a.getAttribute('href') || '').match(/\/tag\/([^/?#]+?)-all\.html/);
if (m) {
try {
const k = tfNorm(decodeURIComponent(m[1]));
if (k) return k.includes(':') ? k : 'tag:' + k;
} catch {}
}
const name = tfNorm(t);
return name ? (/\u2640/.test(t) ? 'female:' : /\u2642/.test(t) ? 'male:' : 'tag:') + name : '';
}
const tfIncL = () => (Array.isArray(state.tfInc) ? state.tfInc : []);
const tfExcL = () => (Array.isArray(state.tfExc) ? state.tfExc : []);
const tfSave = (k, arr) => { state[k] = arr; set(k, arr); };
const tfCount = () => tfIncL().length + tfExcL().length;
const tfQuery = () => [...tfIncL(), ...tfExcL().map((k) => '-' + k)].map((t) => t.replace(/ /g, '_')).join(' ');
let tfEl = null, tfUi = null, tfSig = '', tfLiveT = 0, tfMode = 'inc', tfSaveT = 0;
// Kosakata tag: diingat lintas halaman. tfSeen = id galeri yang sudah dihitung.
const tfVocab = (() => { const v = get('tfVocab2', {}); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; })();
let tfSeenArr = (() => { const v = get('tfSeen2', []); return Array.isArray(v) ? v : []; })();
const tfSeen = new Set(tfSeenArr);
function tfLearn(cards) {
let dirty = false;
for (const c of cards) {
if (!c.dataset.tfk) continue;
const id = c.dataset.gid || gidOfHref(c.getAttribute('href'));
if (!id || tfSeen.has(id)) continue;
tfSeen.add(id); tfSeenArr.push(id);
new Set(c.dataset.tfk.split('|')).forEach((t) => { if (t) tfVocab[t] = (tfVocab[t] || 0) + 1; });
dirty = true;
}
if (dirty && !tfSaveT) tfSaveT = setTimeout(() => {
tfSaveT = 0;
if (tfSeenArr.length > 6000) tfSeenArr = tfSeenArr.slice(-5000);
const keys = Object.keys(tfVocab);
if (keys.length > 4000) keys.sort((a, b) => tfVocab[a] - tfVocab[b]).slice(0, keys.length - 3000).forEach((k) => delete tfVocab[k]);
set('tfVocab2', tfVocab); set('tfSeen2', tfSeenArr);
}, 600);
}
function updateTfBadge() {
const a = document.querySelector('#hmt-tf-nav a');
if (!a) return;
const n = tfCount();
const t = '🏷 Filter' + (n ? ' (' + n + ')' : '');
if (a.textContent !== t) a.textContent = t;
}
// dipanggil tiap grid dibangun ulang: pelajari tag baru + segarkan panel kalau terbuka
function applyTagFilter() {
tfLearn(document.querySelectorAll('.hm-card'));
updateTfBadge();
if (tfEl && tfEl.classList.contains('open')) { clearTimeout(tfLiveT); tfLiveT = setTimeout(tfLive, 150); }
}
function tfPageKeys() {
const m = new Map();
document.querySelectorAll('.hm-card').forEach((c) => {
if (!c.dataset.tfk) return;
new Set(c.dataset.tfk.split('|')).forEach((t) => { if (t) m.set(t, (m.get(t) || 0) + 1); });
});
return m;
}
function tfSigNow() {
const c = document.querySelectorAll('.hm-card');
return c.length + '|' + (c.length ? c[0].href + '|' + c[c.length - 1].href : '');
}
function tfChip(key, cls, extra, onClick) {
const [ns, name] = tfSplit(key);
const b = mk('button', 'tf-chip' + (cls ? ' ' + cls : '')); b.type = 'button'; b.title = key;
if (ns !== 'tag') b.appendChild(mk('span', 'tf-ns ' + (ns === 'female' ? 'f' : ns === 'male' ? 'm' : 'o'), ns === 'female' ? '♀' : ns === 'male' ? '♂' : ns));
b.appendChild(mk('span', '', name));
if (extra != null) b.appendChild(mk('small', '', String(extra)));
b.addEventListener('click', onClick);
return b;
}
function tfChanged() { updateTfBadge(); renderTf(); }
const tfListKey = (mode) => (mode === 'exc' ? 'tfExc' : 'tfInc');
function tfPut(key, mode) {
tfSave('tfInc', tfIncL().filter((x) => x !== key));
tfSave('tfExc', tfExcL().filter((x) => x !== key));
tfSave(tfListKey(mode), [...(mode === 'exc' ? tfExcL() : tfIncL()), key]);
}
function tfRemove(key) {
tfSave('tfInc', tfIncL().filter((x) => x !== key));
tfSave('tfExc', tfExcL().filter((x) => x !== key));
}
function tfToggle(key) {
const cur = tfMode === 'exc' ? tfExcL() : tfIncL();
if (cur.includes(key)) tfRemove(key); else tfPut(key, tfMode);
tfChanged();
}
// isi dari kolom search situs (mis. saat sedang di halaman tag/pencarian)
function tfPreload() {
if (tfCount()) return;
const box = document.getElementById('query-input');
const v = box ? box.value.trim() : '';
if (!v) return;
const inc = [], exc = [];
v.split(/\s+/).forEach((term) => {
const neg = term.startsWith('-');
const k = tfNorm(neg ? term.slice(1) : term);
if (!k || !k.includes(':') || k.startsWith(':') || k.endsWith(':')) return;
(neg ? exc : inc).push(k);
});
if (inc.length) tfSave('tfInc', inc);
if (exc.length) tfSave('tfExc', exc);
}
function tfGoSearch() {
const q = tfQuery();
if (!q) { toast('Pilih tag dulu'); return; }
const inp = document.getElementById('query-input');
const btn = document.getElementById('search-button')
|| [...document.querySelectorAll('#search button, #search input[type=submit], .header-table button, .header-table input[type=submit]')]
.find((b) => /search/i.test(b.textContent || b.value || ''));
const before = location.href;
if (inp && btn) {
inp.value = q;
inp.dispatchEvent(new Event('input', { bubbles: true }));
btn.click();
// kalau tombol situs tidak memindah halaman, pakai URL pencarian langsung
setTimeout(() => { if (location.href === before) location.href = '/search.html?' + encodeURIComponent(q); }, 600);
} else {
location.href = '/search.html?' + encodeURIComponent(q);
}
}
function renderTfTags() {
if (!tfUi) return;
tfSig = tfSigNow();
const raw = tfUi.input.value;
const q = tfNorm(raw);
tfUi.x.classList.toggle('on', !!raw);
const inc = new Set(tfIncL()), exc = new Set(tfExcL());
const page = tfPageKeys();
const pageTags = [...page].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const others = Object.entries(tfVocab).filter(([k]) => !page.has(k)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const match = ([k]) => !q || k.includes(q);
const chip = ([k, n]) => tfChip(k, inc.has(k) ? 'req' : exc.has(k) ? 'hide' : '', n, () => tfToggle(k));
// saran tag baru (kalau yang diketik belum dikenal)
const known = (name) => pageTags.some(([k]) => tfSplit(k)[1] === name) || others.some(([k]) => tfSplit(k)[1] === name);
let sug = [];
if (q) {
const i = q.indexOf(':');
if (i > 0 && i < q.length - 1) sug = [q];
else if (i < 0 && !known(q)) sug = ['female:' + q, 'male:' + q, 'tag:' + q];
}
tfUi.sugWrap.style.display = sug.length ? '' : 'none';
tfUi.sug.replaceChildren(...sug.map((k) => tfChip(k, '', null, () => { tfPut(k, tfMode); tfUi.input.value = ''; tfChanged(); })));
const listA = pageTags.filter(match).slice(0, 250).map(chip);
tfUi.tags.replaceChildren(...(listA.length ? listA : [mk('div', 'tf-empty', pageTags.length ? (q ? T('Tidak ada yang cocok di halaman ini.') : '') : T('Belum ada tag terbaca di halaman ini'))]));
tfUi.tagsCnt.textContent = pageTags.length ? T('{0} tag', pageTags.length, pageTags.length === 1 ? '' : 's') : '';
const matchB = others.filter(match);
const listB = matchB.slice(0, q ? 250 : 80).map(chip);
tfUi.more.style.display = listB.length ? '' : 'none';
tfUi.tags2.replaceChildren(...listB);
tfUi.tags2Cnt.textContent = matchB.length > listB.length ? T('{0} dari {1} (ketik untuk cari)', listB.length, matchB.length) : T('{0} tag', matchB.length, matchB.length === 1 ? '' : 's');
const names = new Set(pageTags.map((x) => x[0]));
others.slice(0, 300).forEach(([k]) => names.add(k));
tfUi.dl.replaceChildren(...[...names].slice(0, 500).map((k) => { const o = document.createElement('option'); o.value = k; return o; }));
}
function tfLive() {
if (!tfUi || !tfEl || !tfEl.classList.contains('open')) return;
if (tfSigNow() !== tfSig) renderTfTags();
}
function renderTf() {
if (!tfUi) return;
const inc = tfIncL(), exc = tfExcL();
const n = inc.length + exc.length;
tfUi.tgt.forEach((b) => b.classList.toggle('on', b.dataset.v === tfMode));
tfUi.hint.style.display = n ? 'none' : '';
tfUi.active.style.display = n ? '' : 'none';
const rm = (cls) => (k) => tfChip(k, cls, '×', () => { tfRemove(k); tfChanged(); });
tfUi.chips.replaceChildren(...inc.map(rm('req')), ...exc.map(rm('hide')));
tfUi.code.textContent = tfQuery();
tfUi.go.textContent = n ? T('🔍 Cari ({0})', n) : T('🔍 Cari');
tfUi.go.classList.toggle('dis', !n);
tfUi.clear.style.visibility = n ? '' : 'hidden';
renderTfTags();
}
function buildTf() {
tfEl = mk('div'); tfEl.id = 'hmt-tf';
tfEl.setAttribute('role', 'dialog'); tfEl.setAttribute('aria-label', T('Filter Tag'));
const head = mk('div', 'tf-head');
const close = mk('button', 'tf-close', '✕'); close.type = 'button'; close.title = T('Tutup (Esc)');
close.addEventListener('click', closeTf);
head.append(mk('div', 'tf-title', T('Filter Tag')), close);
const top = mk('div', 'tf-top');
const seg = mk('div', 'tf-seg');
const tgt = [['inc', 'Sertakan'], ['exc', 'Kecualikan']].map(([v, l]) => {
const b = mk('button'); b.type = 'button'; b.dataset.v = v;
b.append(mk('i'), document.createTextNode(T(l)));
b.addEventListener('click', () => { tfMode = v; tfUi.tgt.forEach((x) => x.classList.toggle('on', x.dataset.v === v)); });
seg.appendChild(b); return b;
});
const sbox = mk('div', 'tf-search');
const input = document.createElement('input');
input.type = 'text'; input.className = 'tf-in'; input.placeholder = T('Cari tag… (mis. female:insect)');
input.setAttribute('list', 'hmt-tf-dl'); input.autocomplete = 'off'; input.spellcheck = false;
const dl = document.createElement('datalist'); dl.id = 'hmt-tf-dl';
const x = mk('button', 'tf-x', '✕'); x.type = 'button'; x.title = T('Bersihkan');
x.addEventListener('click', () => { input.value = ''; renderTfTags(); input.focus(); });
input.addEventListener('input', renderTfTags);
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
const q = tfNorm(input.value);
if (!q) { tfGoSearch(); return; }
let key = '';
const i = q.indexOf(':');
if (i > 0 && i < q.length - 1) key = q;
else {
const cands = [...new Set([...tfPageKeys().keys(), ...Object.keys(tfVocab)])].filter((k) => tfSplit(k)[1] === q);
cands.sort((a, b) => (tfVocab[b] || 0) - (tfVocab[a] || 0));
key = cands[0] || '';
}
if (!key) { toast('Tag belum dikenal. Pilih namespace di bawah (♀ / ♂ / tag).'); return; }
tfPut(key, tfMode); input.value = ''; tfChanged();
});
sbox.append(input, x, dl);
top.append(seg, sbox);
const scroll = mk('div', 'tf-scroll');
const hint = mk('div', 'tf-hint', T('Pilih mode di atas, klik tag di bawah, lalu tekan Cari. Hasilnya dari seluruh galeri, bukan hanya halaman ini.'));
const active = mk('div', 'tf-active');
const chips = mk('div', 'tf-chips');
const code = mk('div', 'tf-code');
active.append(chips, code);
const sugWrap = mk('div'); sugWrap.style.marginBottom = '14px';
const sl = mk('div', 'tf-lab'); sl.append(mk('span', '', T('Tambah tag baru')), mk('em', '', T('pilih jenisnya')));
const sug = mk('div', 'tf-chips');
sugWrap.append(sl, sug);
const tl = mk('div', 'tf-lab'); const tagsCnt = mk('em');
tl.append(mk('span', '', T('Tag di halaman ini')), tagsCnt);
const tagsBox = mk('div', 'tf-chips');
const more = mk('div'); more.style.marginTop = '16px';
const tl2 = mk('div', 'tf-lab'); const tags2Cnt = mk('em');
tl2.append(mk('span', '', T('Dari halaman lain')), tags2Cnt);
const tagsBox2 = mk('div', 'tf-chips');
more.append(tl2, tagsBox2);
scroll.append(hint, active, sugWrap, tl, tagsBox, more);
const foot = mk('div', 'tf-foot');
const clear = mk('button', 'tf-clear', T('Kosongkan')); clear.type = 'button';
clear.addEventListener('click', () => { tfSave('tfInc', []); tfSave('tfExc', []); tfChanged(); });
const go = mk('button', 'tf-go', T('🔍 Cari')); go.type = 'button';
go.addEventListener('click', tfGoSearch);
foot.append(clear, go);
tfEl.append(head, top, scroll, foot);
document.body.appendChild(tfEl);
tfUi = { input, x, dl, hint, active, chips, code, sugWrap, sug, tags: tagsBox, tagsCnt, more, tags2: tagsBox2, tags2Cnt, tgt, clear, go };
}
function openTf() {
if (isReader) return;
if (!tfEl) buildTf();
else if (!tfEl.isConnected) document.body.appendChild(tfEl);
tfEl.classList.add('open');
tfPreload();
applyTagFilter();
renderTf();
updateTfBadge();
}
function closeTf() { if (tfEl) tfEl.classList.remove('open'); }
function toggleTf() { if (tfEl && tfEl.classList.contains('open')) closeTf(); else openTf(); }
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && tfEl && tfEl.classList.contains('open') && !(overlay && overlay.classList.contains('open'))) {
if (document.getElementById('hmt-dl')) return;
closeTf(); return;
}
if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyT' && !isReader) { e.preventDefault(); e.stopImmediatePropagation(); toggleTf(); }
}, true);
/* ====================================================================
* ROMAJI: tulisan Jepang di judul -> huruf latin
* Kana (hiragana/katakana) diubah offline. Kanji memakai kamus kuromoji yang dimuat dari CDN
* (hanya kalau "Ubah kanji juga" aktif); sebelum kamus siap, kanji dibiarkan apa adanya.
* ==================================================================== */
const RM_B = {};
('あ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 ゐi ゑe をo が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 ゔvu ' +
'ぁa ぃi ぅu ぇe ぉo ゃya ゅyu ょyo ゎwa ゕka ゖke').split(' ').forEach((t) => { RM_B[t[0]] = t.slice(1); });
const RM_SY = { 'ゃ': 'a', 'ゅ': 'u', 'ょ': 'o' };
const RM_SV = { 'ぁ': 'a', 'ぃ': 'i', 'ぅ': 'u', 'ぇ': 'e', 'ぉ': 'o' };
const RM_PUNCT = { '【': '[', '】': ']', '「': '"', '」': '"', '『': '"', '』': '"', '、': ', ', '。': '. ', '・': ' ', '〜': '~', ' ': ' ' };
const RM_JP = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f々〆]/;
const RM_KANARUN = /[\u3041-\u3096\u30a1-\u30fa\u30fc]+/g;
const RM_PART = { 'は': 'wa', 'へ': 'e', 'を': 'o' };
const rmCache = new Map();
let kmWorker = null, kmState = 'idle'; // idle | loading | ready | error
const kmPending = new Map(), kmInflight = new Map(); // teks asli -> teks NFKC
let kmFlushT = 0, kmApplyT = 0;
const rmKata2Hira = (t) => t.replace(/[\u30a1-\u30f6]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
const rmCap = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w);
function rmCombine(base, n) {
const y = RM_SY[n];
if (y) {
if (/^(shi|chi|ji)$/.test(base)) return base.slice(0, -1) + y;
if (/^(ki|gi|ni|hi|bi|pi|mi|ri|fu|te|de|vu)$/.test(base)) return base.slice(0, -1) + 'y' + y;
return null;
}
const v = RM_SV[n];
if (!v) return null;
if (base === 'u') return v === 'u' ? null : 'w' + v;
if (base === 'i') return v === 'e' ? 'ye' : null;
if (/^(fu|vu|tsu|te|de|to|do|shi|ji|chi|su|zu|ku|gu)$/.test(base)) return base.replace(/[aiueo]$/, '') + v;
return null;
}
// satu rangkaian kana -> romaji (Hepburn, tanpa makron)
function rmKana(src) {
const s = rmKata2Hira(src);
let out = '', i = 0, gem = false;
const push = (r) => { if (gem) { r = r.startsWith('ch') ? 't' + r : r.charAt(0) + r; gem = false; } out += r; };
while (i < s.length) {
const c = s[i];
if (c === 'っ') { gem = true; i++; continue; }
if (c === 'ー') { const m = out.match(/[aiueo]$/); if (m) out += m[0]; i++; continue; }
if (c === 'ん') { const nx = s[i + 1]; push(nx && 'あいうえおやゆよ'.includes(nx) ? "n'" : 'n'); i++; continue; }
const base = RM_B[c];
if (base !== undefined) {
const n = s[i + 1];
const comb = n ? rmCombine(base, n) : null;
if (comb) { push(comb); i += 2; } else { push(base); i++; }
continue;
}
gem = false; out += c; i++;
}
return out;
}
const rmSimple = (r) => (state.romajiLong === 'keep' ? r : r.replace(/ou/g, 'o').replace(/oo/g, 'o').replace(/uu/g, 'u'));
const rmChunk = (str) => str.replace(RM_KANARUN, (run) => rmSimple(rmKana(run)));
const rmPunct = (t) => t.replace(/[【】「」『』、。・〜 ]/g, (c) => RM_PUNCT[c]);
const rmFinish = (r) => r.replace(/\s{2,}/g, ' ').trim();
function rmKanaOnly(text) {
const t = text.normalize('NFKC');
return rmFinish(rmPunct(t.replace(RM_KANARUN, (run) => rmCap(rmSimple(rmKana(run))))));
}
// token dari worker: { s: surface, r: reading, p: pos, d: pos_detail_1, w: word_position }
function rmFromTokens(t, toks) {
let out = '', pos = 0, prevJp = false;
for (const k of toks) {
const surf = k.s;
const start = k.w - 1;
const gap = t.slice(pos, start);
pos = start + surf.length;
const jp = RM_JP.test(surf);
const particle = k.p === '助詞';
const attach = k.p === '助動詞' || k.d === '接尾';
let piece;
if (jp) {
if (particle && RM_PART[surf]) piece = RM_PART[surf];
else piece = rmChunk(k.r && k.r !== '*' ? k.r : surf);
} else piece = rmPunct(surf);
const sep = gap || (jp && prevJp && !attach ? ' ' : '');
const newWord = !out || sep !== '' || !prevJp;
if (jp && newWord && !particle) piece = rmCap(piece);
out += sep + piece;
prevJp = jp;
}
return rmFinish(out);
}
// Hasil lengkap (dengan kanji) datang async dari worker. Selama belum ada, tampilkan versi kana saja.
function rmConv(text) {
if (!RM_JP.test(text)) return text;
const hit = rmCache.get(text);
if (hit !== undefined) return hit;
const useDict = state.romajiKanji && kmState === 'ready' && kmWorker && !/[\ud800-\udfff]/.test(text);
let r;
try { r = rmKanaOnly(text); } catch (e) { console.warn('[HMT] romaji gagal', e); r = text; }
if (useDict) { rmQueue(text); return r; }
// kamus belum siap tapi diharapkan -> jangan di-cache, hitung ulang nanti
if (!(state.romajiKanji && (kmState === 'idle' || kmState === 'loading'))) {
rmCache.set(text, r);
if (rmCache.size > 3000) rmCache.clear();
}
return r;
}
function rmQueue(text) {
if (kmPending.has(text) || kmInflight.has(text)) return;
kmPending.set(text, text.normalize('NFKC'));
if (!kmFlushT) kmFlushT = setTimeout(() => {
kmFlushT = 0;
if (!kmWorker || !kmPending.size) return;
const batch = [...kmPending];
kmPending.clear();
batch.forEach(([o, n]) => kmInflight.set(o, n));
kmWorker.postMessage({ type: 'tok', texts: [...new Set(batch.map((x) => x[1]))] });
}, 0);
}
function kmOnTok(out) {
for (const [orig, norm] of [...kmInflight]) {
if (!(norm in out)) continue;
kmInflight.delete(orig);
let r;
try { r = out[norm] ? rmFromTokens(norm, out[norm]) : rmKanaOnly(orig); }
catch (e) { r = rmKanaOnly(orig); }
rmCache.set(orig, r);
}
if (rmCache.size > 3000) rmCache.clear();
clearTimeout(kmApplyT);
kmApplyT = setTimeout(romajiApply, 30);
}
// Kode worker: kuromoji dijalankan di thread terpisah supaya halaman tidak membeku saat kamus dibangun.
const KM_GLUE = [
';(function () {',
' var tk = null;',
' self.onmessage = function (e) {',
' var m = e.data;',
" if (m.type === 'init') {",
' try {',
' kuromoji.builder({ dicPath: m.dicPath }).build(function (err, t) {',
" if (err || !t) { self.postMessage({ type: 'error', msg: 'kamus gagal diunduh (' + String((err && err.message) || err || 'jaringan/CORS') + ')' }); return; }",
" tk = t; self.postMessage({ type: 'ready' });",
' });',
" } catch (x) { self.postMessage({ type: 'error', msg: 'build: ' + String((x && x.message) || x) }); }",
" } else if (m.type === 'tok' && tk) {",
' var out = {};',
' for (var i = 0; i < m.texts.length; i++) {',
' var txt = m.texts[i];',
' try {',
' out[txt] = tk.tokenize(txt).map(function (k) { return { s: k.surface_form, r: k.reading, p: k.pos, d: k.pos_detail_1, w: k.word_position }; });',
' } catch (x) { out[txt] = null; }',
' }',
" self.postMessage({ type: 'tok', out: out });",
' }',
' };',
'})();',
].join('\n');
// Muat kuromoji + kamus (di worker). Beberapa mirror dicoba berurutan; kegagalan dicatat di Console ([HMT]).
function kmLoad() {
if (kmState !== 'idle') return;
kmState = 'loading';
toast('Memuat kamus kanji… (pertama kali agak lama, halaman tetap bisa dipakai)', { ms: 3500 });
const bases = [
'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/',
'https://fastly.jsdelivr.net/npm/kuromoji@0.1.2/',
'https://gcore.jsdelivr.net/npm/kuromoji@0.1.2/',
'https://unpkg.com/kuromoji@0.1.2/',
];
const errs = [];
const fail = () => {
kmState = 'error';
console.warn('[HMT] kamus kanji gagal dimuat:', errs);
rmCache.clear(); romajiApply();
toast(T('Gagal memuat kamus kanji ({0}). Kalau diblokir ekstensi pemblokir iklan, izinkan cdn.jsdelivr.net. Detail di Console, cari [HMT].', tErr(errs[errs.length - 1] || 'tidak diketahui')), { type: 'err', ms: 15000 });
};
const tryBase = (n) => {
if (n >= bases.length) return fail();
const host = bases[n].split('/')[2];
let settled = false, w = null, url = '', timer = 0;
const next = (why) => {
if (settled) return;
settled = true; clearTimeout(timer);
if (w) { try { w.terminate(); } catch {} }
if (url) URL.revokeObjectURL(url);
console.warn('[HMT] ' + host + ': ' + why);
errs.push(host + ': ' + why);
tryBase(n + 1);
};
timer = setTimeout(() => next('timeout (90 detik)'), 90000);
fetch(bases[n] + 'build/kuromoji.js')
.then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
.then((code) => {
if (settled) return;
url = URL.createObjectURL(new Blob([code, '\n', KM_GLUE], { type: 'text/javascript' }));
w = new Worker(url);
w.onmessage = (e) => {
const m = e.data || {};
if (m.type === 'ready') {
if (settled) return;
settled = true; clearTimeout(timer);
URL.revokeObjectURL(url);
kmWorker = w; kmState = 'ready';
rmCache.clear(); romajiApply();
toast('Kamus kanji siap', { type: 'ok', ms: 2500 });
} else if (m.type === 'error') next(m.msg || 'error');
else if (m.type === 'tok') kmOnTok(m.out || {});
};
w.onerror = (e) => next('worker: ' + ((e && e.message) || 'error'));
w.postMessage({ type: 'init', dicPath: bases[n] + 'dict/' });
})
.catch((e) => next('kuromoji.js: ' + ((e && e.message) || e)));
};
tryBase(0);
}
// Terapkan ke judul di halaman. Idempotent: teks asli disimpan di data-rm-orig.
const RM_SEL = '.hm-title, .hm-sub, .hm-it, .hm-ia, #gallery-brand a';
function romajiApply() {
if (isReader) return;
const on = !!state.romaji;
document.querySelectorAll(RM_SEL).forEach((el) => {
if (el.children.length) return;
const cur = el.textContent;
let o = el.dataset.rmOrig;
if (o === undefined || (cur !== o && cur !== el.dataset.rmOut)) { o = cur; el.dataset.rmOrig = o; }
const want = on ? rmConv(o) : o;
el.dataset.rmOut = want;
if (cur !== want) el.textContent = want;
});
}
onReady(() => {
if (isReader) return;
romajiApply();
if (state.romaji && state.romajiKanji) setTimeout(kmLoad, 800);
});
/* ====================================================================
* Deteksi latar terang -> gelapkan (tidak bergantung nama class situs)
* ==================================================================== */
const SKIP_DARK = new Set(['IMG', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'SCRIPT', 'STYLE', 'LINK', 'META', 'VIDEO', 'CANVAS', 'PICTURE', 'SOURCE', 'IFRAME']);
// Kembalikan warna latar (string) kalau elemen berlatar terang & tidak transparan, selain itu null.
function lightBg(el) {
const c = getComputedStyle(el).backgroundColor;
const m = c && c.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)/);
if (!m) return null;
const a = m[4] === undefined ? 1 : (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]);
if (a < 0.5) return null;
const lum = (0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3]) / 255;
return lum > 0.72 ? c : null;
}
function darkenTree(rootEl) {
if (!state.dark || !rootEl || rootEl.nodeType !== 1 || !rootEl.isConnected) return;
const skipSel = state.grid ? '#hmt-overlay, #hmt-dl, #hmt-preview, .hm-thumbs, .hm-grid-wrap, .gallery-content' : '#hmt-overlay, #hmt-dl, #hmt-preview, .hm-thumbs, .hm-grid-wrap';
const all = [rootEl, ...rootEl.querySelectorAll('*')];
for (const el of all) {
if (!(el instanceof HTMLElement) || SKIP_DARK.has(el.tagName) || el.classList.contains('hmt-dk') || el.closest(skipSel)) continue;
const bg = lightBg(el);
if (bg) { el.dataset.hmtBg = bg; el.classList.add('hmt-dk'); }
}
}
let rescanQueued = false;
function rescanDark() {
if (rescanQueued) return;
rescanQueued = true;
requestAnimationFrame(() => { rescanQueued = false; if (document.body) darkenTree(document.body); });
}
/* ---------- Dump diagnostik (tombol di panel Pengaturan) ---------- */
function elPath(el) {
const parts = [];
for (let e = el, i = 0; e && e !== document.body && i < 6; e = e.parentElement, i++) {
const cls = [...e.classList].filter((c) => !/^(hmt-|hm-)/.test(c)).slice(0, 3).map((c) => '.' + c).join('');
parts.unshift(e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + cls);
}
return parts.join(' > ');
}
function openTag(el) {
const h = el.cloneNode(false).outerHTML;
return h.length > 200 ? h.slice(0, 200) + '…' : h;
}
function buildDump() {
const lines = ['URL: ' + location.href, 'Dark: ' + state.dark + ' | Grid: ' + state.grid + ' | v2.5', ''];
lines.push('== Struktur atas body ==');
[...document.body.children].slice(0, 25).forEach((e) => {
const r = e.getBoundingClientRect();
lines.push(`${elPath(e) || e.tagName.toLowerCase()} | ${Math.round(r.width)}x${Math.round(r.height)}`);
});
lines.push('', '== Elemen berlatar terang (warna asli | ukuran | path | tag pembuka) ==');
let n = 0;
for (const el of document.body.querySelectorAll('*')) {
if (!(el instanceof HTMLElement) || SKIP_DARK.has(el.tagName) || el.closest('#hmt-overlay, #hmt-dl, .hm-grid-wrap')) continue;
const bg = el.dataset.hmtBg || lightBg(el);
if (!bg) continue;
const r = el.getBoundingClientRect();
lines.push(`${bg} | ${Math.round(r.width)}x${Math.round(r.height)} | ${elPath(el)} | ${openTag(el)}`);
if (++n >= 120) { lines.push('... (dipotong)'); break; }
}
if (!n) lines.push('(tidak ada)');
return lines.join('\n');
}
function copyDump(btn) {
const text = buildDump();
console.log('[HMT dump]\n' + text);
const done = (ok) => {
btn.textContent = T(ok ? 'Tersalin ✓' : 'Lihat console (F12)');
setTimeout(() => { btn.textContent = T('Salin dump'); }, 2500);
};
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy');
ta.remove(); done(ok);
} catch (e) { done(false); }
};
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(() => done(true), fallback);
else fallback();
}
/* ====================================================================
* URL gambar reader (dipakai reader webtoon, thumbnail besar, dan hover preview)
* ==================================================================== */
// Daftar URL kandidat untuk satu gambar. Dicoba berurutan kalau ada yang gagal.
// Catatan: subdomain gambar penuh = w{n} (webp) / a{n} (avif), BUKAN "aa".
function candidates(f) {
const gg = window.gg;
const id = window.galleryinfo && window.galleryinfo.id;
const exts = [];
if (f.haswebp !== 0) exts.push('webp');
if (f.hasavif) exts.push('avif');
if (!exts.length) exts.push('webp');
const out = [];
const push = (u) => { if (u && !out.includes(u)) out.push(u); };
exts.forEach((ext) => {
if (typeof window.url_from_url_from_hash === 'function') {
try { push(window.url_from_url_from_hash(id, f, ext)); } catch (e) { /* lanjut */ }
}
try {
const path = gg.b + gg.s(f.hash) + '/' + f.hash + '.' + ext;
const n = parseInt(gg.s(f.hash), 10);
const prefix = ext === 'webp' ? 'w' : 'a';
const first = 1 + gg.m(n);
[first, 1, 2, 3].forEach((k) => push(`https://${prefix}${k}.gold-usergeneratedcontent.net/${path}`));
} catch (e) { /* gg tidak lengkap */ }
});
return out;
}
/* ====================================================================
* THUMBNAIL BESAR (halaman galeri): ganti strip thumbnail kecil dengan grid
* besar yang memakai gambar reader.
* ==================================================================== */
// Cari kumpulan thumbnail asli: tautan ke /reader/ yang berisi
, dikelompokkan per induk.
function findThumbGroup() {
const as = [...document.querySelectorAll('a[href*="/reader/"]')]
.filter((a) => a.querySelector('img') && !a.closest('.hm-thumbs, .hm-grid-wrap, .gallery-content, #hmt-overlay'));
if (as.length < 2) return null;
const groups = new Map();
for (const a of as) {
const p = a.parentElement;
if (!p) continue;
const k = p.tagName + '|' + p.className;
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(a);
}
let best = null;
for (const g of groups.values()) if (!best || g.length > best.length) best = g;
if (!best || best.length < 2) return null;
let lca = best[0].parentElement;
while (lca && !best.every((a) => lca.contains(a))) lca = lca.parentElement;
if (!lca || lca === document.body || lca === root || lca.matches('.container, .content, .top-content, .bottom-content')) return null;
return { anchors: best, lca };
}
let infoReqTimer = 0;
function requestInfo() { // kalau situs belum memuat galleryinfo/gg, muat sendiri setelah jeda
if (infoReqTimer) return;
infoReqTimer = setTimeout(() => {
const idm = location.pathname.match(/(\d+)\.html$/);
const inject = (src) => {
const s = document.createElement('script');
s.src = src; s.onload = () => buildThumbs();
(document.head || root).appendChild(s);
};
if (!window.galleryinfo && idm) {
const ex = [...document.scripts].find((s) => /\/galleries\/\d+\.js/.test(s.src));
inject(ex ? ex.src : `https://ltn.gold-usergeneratedcontent.net/galleries/${idm[1]}.js`);
}
if (!window.gg) {
const ex = [...document.scripts].find((s) => /\/gg\.js/.test(s.src));
inject(ex ? ex.src : 'https://ltn.gold-usergeneratedcontent.net/gg.js');
}
}, 2500);
}
function buildThumbs() {
if (!isGallery || !state.thumbs) return;
const info = window.galleryinfo;
const gg = window.gg;
if (!(info && info.files && info.files.length && gg && typeof gg.m === 'function')) { requestInfo(); return; }
const g = findThumbGroup();
if (!g) return;
const idm = location.pathname.match(/(\d+)\.html$/);
const gid = info.id || (idm && idm[1]) || '';
const siteSrc = g.anchors.map((a) => {
const im = a.querySelector('img');
let s = im ? (im.getAttribute('data-src') || im.getAttribute('src') || '') : '';
if (!s || s.startsWith('data:')) {
const so = a.querySelector('source[data-srcset], source[srcset]');
s = so ? ((so.getAttribute('data-srcset') || so.getAttribute('srcset') || '').trim().split(/\s+/)[0]) : '';
}
if (s.startsWith('data:')) s = '';
if (s.startsWith('//')) s = 'https:' + s;
return s;
});
const host = thumbHost || mk('div', 'hm-thumbs');
const frag = document.createDocumentFragment();
info.files.forEach((f, i) => {
const a = mk('a', 'hm-th');
a.href = `/reader/${gid}.html#${i + 1}`;
a.dataset.i = String(i + 1);
const urls = candidates(f);
a._urls = urls;
const img = document.createElement('img');
img.loading = 'lazy'; img.decoding = 'async'; img.alt = '';
if (f.width && f.height) {
a.dataset.r = (f.width / f.height).toFixed(4);
img.width = f.width; img.height = f.height;
img.style.aspectRatio = `${f.width} / ${f.height}`;
}
const list = state.thumbReader ? urls : [siteSrc[i], ...urls].filter(Boolean);
let k = 0;
img.addEventListener('error', () => { if (++k < list.length) img.src = list[k]; });
if (list.length) img.src = list[0];
a.appendChild(img);
a.appendChild(mk('span', 'hm-thn', String(i + 1)));
frag.appendChild(a);
});
host.dataset.n = String(info.files.length);
host.replaceChildren(frag);
attachPreview(host);
thumbHost = host;
thumbOrig = g.lca;
if (host.previousElementSibling !== g.lca) g.lca.after(host);
apply();
}
function thumbsMaybe() {
if (!isGallery || !state.thumbs) return;
if (thumbHost && thumbHost.isConnected && thumbOrig && thumbOrig.isConnected) return;
buildThumbs();
}
function rebuildThumbs() {
hidePreview();
if (thumbOrig) thumbOrig.classList.remove('hmt-hide');
if (thumbHost) thumbHost.remove();
thumbHost = null; thumbOrig = null;
root.classList.remove('hm-thumbs-on');
if (state.thumbs) buildThumbs();
apply();
}
/* ====================================================================
* HOVER PREVIEW (di atas thumbnail halaman galeri)
* ==================================================================== */
let pv = null, pvImg = null, pvLbl = null;
let pvTimer = 0, pvCur = null, pvUrls = null, pvIdx = 0;
const pvPt = { x: 0, y: 0 };
function ensurePreview() {
if (pv && pv.isConnected) return;
pv = mk('div'); pv.id = 'hmt-preview';
pvImg = document.createElement('img'); pvImg.alt = '';
pvLbl = mk('div', 'hmt-pv-n');
pv.append(pvImg, pvLbl);
pvImg.addEventListener('error', () => { if (pvUrls && ++pvIdx < pvUrls.length) pvImg.src = pvUrls[pvIdx]; });
pvImg.addEventListener('load', placePreview);
document.body.appendChild(pv);
}
function placePreview() {
if (!pvCur || !pv || pv.style.display !== 'block') return;
const vw = window.innerWidth, vh = window.innerHeight, gap = 14;
let r = +pvCur.dataset.r;
if (!r) r = pvImg.naturalWidth && pvImg.naturalHeight ? pvImg.naturalWidth / pvImg.naturalHeight : 0.7;
const rect = state.pvMode === 'cursor'
? { left: pvPt.x + 6, right: pvPt.x + 6, top: pvPt.y, bottom: pvPt.y }
: pvCur.getBoundingClientRect();
const spaceR = vw - rect.right - gap - 8;
const spaceL = rect.left - gap - 8;
const right = spaceR >= spaceL; // sisi yang lebih lega
const availW = Math.max(120, (right ? spaceR : spaceL) - 2); // -2: border
const maxH = Math.min(vh - 16, vh * state.pvSize / 100) - 2;
const h = Math.min(maxH, availW / r);
const w = h * r;
pvImg.style.width = Math.round(w) + 'px';
pvImg.style.height = Math.round(h) + 'px';
const x = right ? rect.right + gap : rect.left - gap - w;
const y = Math.min(Math.max(8, (rect.top + rect.bottom) / 2 - h / 2), vh - h - 8);
pv.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
}
function showPreview(cell) {
if (!state.pvOn || !cell || !cell.isConnected) return;
ensurePreview();
pvCur = cell;
pvUrls = cell._urls || [];
pvIdx = 0;
const src = pvUrls[0] || '';
if (src && pvImg.getAttribute('src') !== src) pvImg.src = src;
pvLbl.textContent = cell.dataset.i + ' / ' + (cell.parentElement ? cell.parentElement.dataset.n || '' : '');
pv.style.display = 'block';
placePreview();
}
function hidePreview() {
clearTimeout(pvTimer);
pvCur = null;
if (pv) pv.style.display = 'none';
}
function attachPreview(host) {
if (host.dataset.pv) return;
host.dataset.pv = '1';
host.addEventListener('mouseover', (e) => {
const c = e.target.closest && e.target.closest('.hm-th');
clearTimeout(pvTimer);
if (!c) { hidePreview(); return; }
if (!state.pvOn) return;
const shown = pv && pv.style.display === 'block';
if (c === pvCur && shown) return;
pvTimer = setTimeout(() => showPreview(c), shown ? 0 : state.pvDelay);
});
host.addEventListener('mouseleave', hidePreview);
host.addEventListener('mousemove', (e) => {
pvPt.x = e.clientX; pvPt.y = e.clientY;
if (state.pvMode === 'cursor' && pvCur) placePreview();
}, { passive: true });
}
window.addEventListener('scroll', () => { if (pvCur) hidePreview(); }, { passive: true });
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') hidePreview(); }, true);
// Sembunyikan pagination di atas daftar (yang di bawah dibiarkan) dan footer "Donate BTC".
let footerEl = null;
let lastFooterScan = 0;
function trimChrome(force) {
const gc = document.querySelector('.gallery-content');
if (gc) {
document.querySelectorAll('.page-container').forEach((pc) => {
// pagination yang posisinya sebelum daftar galeri = yang di atas
if (!pc.classList.contains('hmt-hide') && (pc.compareDocumentPosition(gc) & Node.DOCUMENT_POSITION_FOLLOWING)) {
pc.classList.add('hmt-hide');
}
});
}
if (footerEl && footerEl.isConnected) return;
const now = Date.now();
if (!force && now - lastFooterScan < 500) return;
lastFooterScan = now;
const body = document.body;
if (!body) return;
const tw = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
let n;
while ((n = tw.nextNode())) {
if (!/^\s*Donate\s*BTC/i.test(n.nodeValue)) continue;
let t = n.parentElement;
if (!t || t.closest('#hmt-overlay')) continue;
while (t.parentElement && t.parentElement !== body && t.parentElement.textContent.trim().length < 100) t = t.parentElement;
t.classList.add('hmt-hide');
footerEl = t;
break;
}
}
if (!isReader) {
// Dijalankan langsung di callback observer (microtask) -> selesai sebelum browser sempat menggambar.
// stickyNavSync ikut dijalankan di sini (microtask, sebelum paint) supaya banner baru hasil render ulang situs langsung dikunci
const run = () => { ensureStyle(); gridify(); trimChrome(); ensureNav(); thumbsMaybe(); stickyNavSync(); };
new MutationObserver((muts) => {
let need = false;
for (const m of muts) {
const t = m.target.nodeType === 1 ? m.target : m.target.parentElement;
if (t && t.closest && t.closest('.hm-grid-wrap, .hm-thumbs, #hmt-preview, #hmt-overlay, #hmt-dl')) continue; // abaikan perubahan buatan sendiri
need = true;
m.addedNodes.forEach((n) => { if (n.nodeType === 1) darkenTree(n); });
}
if (need) run();
}).observe(root, { childList: true, subtree: true });
// stylesheet situs yang datang belakangan mengubah warna latar -> cek ulang
document.addEventListener('load', (e) => { if (e.target && e.target.tagName === 'LINK') rescanDark(); }, true);
onReady(() => { run(); trimChrome(true); darkenTree(document.body); trimmed = true; apply(); });
// Situs mengganti class/style elemen (mis. banner judul) -> class gelap hilang -> latar terang sempat kedip.
// Gelapkan lagi di microtask yang sama, sebelum browser menggambar.
onReady(() => {
new MutationObserver((muts) => {
if (!state.dark) return;
for (const m of muts) {
const t = m.target;
if (!(t instanceof HTMLElement) || t === root || t === document.body) continue;
if (m.attributeName === 'class' && t.classList.contains('hmt-dk') && t.dataset.hmtBg === undefined) continue;
if (t.classList.contains('hmt-dk') || SKIP_DARK.has(t.tagName)) continue;
if (t.closest('#hmt-overlay, #hmt-dl, #hmt-preview, .hm-thumbs, .hm-grid-wrap, .gallery-content')) continue;
const bg = lightBg(t);
if (bg) { t.dataset.hmtBg = bg; t.classList.add('hmt-dk'); }
}
}).observe(document.body, { attributes: true, attributeFilter: ['class', 'style'], subtree: true });
});
window.addEventListener('load', () => { trimChrome(true); darkenTree(document.body); setTimeout(rescanDark, 1000); }, { once: true });
}
/* ====================================================================
* INTRO KARTU (animasi muncul satu per satu dari kiri-atas)
* Cara kerja:
* 1) kartu langsung disembunyikan (class hmt-pre) supaya tidak sempat tampil dulu
* 2) tunggu sampai daftar kartu stabil + gambar di layar termuat
* 3) tiap kartu yang terlihat diputar animasinya dengan jeda bertingkat (urut DOM = kiri -> kanan, atas -> bawah)
* Pakai Web Animations API, jadi tidak tergantung class/CSS lain.
* ==================================================================== */
const introInView = (c) => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.top < innerHeight && r.bottom > 0; };
// kelompokkan nilai posisi yang berdekatan jadi nomor baris/kolom (0, 1, 2, ...)
function introCluster(vals) {
const uniq = [...new Set(vals.map((v) => Math.round(v)))].sort((a, b) => a - b);
const idx = new Map(); let g = -1, last = -1e9;
uniq.forEach((v) => { if (v - last > 8) g++; idx.set(v, g); last = v; });
return vals.map((v) => idx.get(Math.round(v)));
}
// putar animasi pada kartu yang terlihat; mengembalikan total durasi (ms)
function introPlay(cards, offset) {
cards.forEach((c) => { try { c.getAnimations().forEach((a) => a.cancel()); } catch (e) { /* abaikan */ } });
const vis = cards.filter(introInView);
if (!vis.length) return 0;
const step = Math.max(0, +state.introStep || 0);
const order = state.introOrder;
let raw;
if (order === 'diag') {
const rects = vis.map((c) => c.getBoundingClientRect());
const rows = introCluster(rects.map((r) => r.top)), cols = introCluster(rects.map((r) => r.left));
raw = vis.map((_, k) => (rows[k] + cols[k]) * step * 1.4);
} else if (order === 'rand') {
const perm = vis.map((_, k) => k).sort(() => Math.random() - .5);
raw = perm.map((rank) => rank * step);
} else raw = vis.map((_, k) => k * step);
const mx = Math.max(0, ...raw), sc = mx > INTRO.maxStagger ? INTRO.maxStagger / mx : 1;
const keys = Object.keys(INTRO_FX);
const dur0 = INTRO.dur * (+state.introSpeed || 1);
let total = 0;
vis.forEach((c, k) => {
const fx = INTRO_FX[state.introStyle === 'mix' ? keys[Math.floor(Math.random() * keys.length)] : state.introStyle] || INTRO_FX.rise;
const dur = dur0 * (fx.d || 1), delay = Math.round(raw[k] * sc) + (offset || 0);
total = Math.max(total, delay + dur);
try { c.animate(fx.kf, { duration: dur, delay, easing: fx.ease, fill: 'backwards' }); } catch (e) { /* browser lama: kartu tampil biasa */ }
});
return total;
}
// animasi header: navbar dulu, banner menyusul; mengembalikan { total, offset } (offset = kapan kartu mulai)
function introHeaders() {
const nav = document.querySelector('.navbar');
const bn = isGallery ? null : (document.querySelector('.hmt-banner:not(.navbar)') || document.querySelector('.list-title'));
const dur0 = 650 * (+state.introSpeed || 1);
let total = 0, any = false;
const items = (el) => {
const out = [];
[...el.children].forEach((ch) => {
const t = ch.tagName;
if (t === 'SCRIPT' || t === 'STYLE' || t === 'NOSCRIPT') return;
if (t === 'NAV') { const li = ch.querySelectorAll('li'); out.push(...(li.length ? li : ch.children)); } else out.push(ch);
});
return out;
};
const run = (el, key, base) => {
const fx = HEAD_FX[key];
if (!el || !fx || key === 'none') return;
try { el.getAnimations({ subtree: true }).forEach((a) => a.cancel()); } catch (e) { /* abaikan */ }
any = true;
try {
if (key === 'items') {
const list = items(el);
if (!list.length) { el.animate(HEAD_FX.fade.kf, { duration: dur0, delay: base, easing: 'ease-out', fill: 'backwards' }); total = Math.max(total, base + dur0); return; }
const dur = dur0 * .9;
list.forEach((it, k) => {
const delay = base + k * 70;
it.animate([{ opacity: 0, transform: 'translateY(-14px)' }, { opacity: 1, transform: 'none' }], { duration: dur, delay, easing: 'cubic-bezier(.22, 1, .36, 1)', fill: 'backwards' });
total = Math.max(total, delay + dur);
});
} else {
const dur = dur0 * (fx.d || 1);
el.animate(fx.kf, { duration: dur, delay: base, easing: fx.ease, fill: 'backwards' });
total = Math.max(total, base + dur);
}
} catch (e) { /* browser lama: header tampil biasa */ }
};
run(nav, state.introNav, 0);
const navOn = !!state.introNav && state.introNav !== 'none';
run(bn, state.introBanner, navOn ? 160 : 0);
// halaman galeri: cover + judul/info muncul berurutan setelah navbar
let nBlk = 0;
const bfx = isGallery ? INTRO_FX[state.introBlock] : null;
if (bfx) {
const big = '.hm-thumbs, .hm-grid-wrap';
const picks = [];
['.cover-column', '.dj-gallery', '#gallery-brand', '.gallery-info'].forEach((sel) => document.querySelectorAll(sel).forEach((el) => {
if (!picks.includes(el) && !el.querySelector(big)) picks.push(el); // wadah yang memuat thumbnail/kartu dilewati
}));
const tops = picks.filter((el) => !picks.some((o) => o !== el && o.contains(el)))
.sort((a, b) => (a.compareDocumentPosition(b) & 4 ? -1 : 1));
tops.forEach((el, k) => {
try {
el.getAnimations({ subtree: true }).forEach((a) => a.cancel());
const dur = 650 * (+state.introSpeed || 1) * (bfx.d || 1), delay = (navOn ? 140 : 0) + k * 120;
el.animate(bfx.kf, { duration: dur, delay, easing: bfx.ease, fill: 'backwards' });
total = Math.max(total, delay + dur); any = true; nBlk++;
} catch (e) { /* abaikan */ }
});
}
return { total, offset: any ? (nBlk ? 140 + nBlk * 120 + 160 : 300) : 0 };
}
// jalankan semua: header dulu, lalu kartu satu per satu; mengembalikan total durasi (ms)
function introRun(cards) {
const h = introHeaders();
const t = introPlay(cards, h.offset);
return Math.max(h.total, t);
}
const ITEM_SEL = '.hm-card, .hm-th'; // kartu daftar/related + thumbnail galeri
const itemSig = () => {
const it = document.querySelectorAll(ITEM_SEL), n = it.length;
return n + ':' + (n ? it[0].getAttribute('href') + '|' + it[n - 1].getAttribute('href') : '');
};
// pratinjau dari panel pengaturan: putar ulang + latar panel dibuat transparan sebentar supaya kartu terlihat
let introPvT = 0, introPvR = 0;
function introPreview() {
if (isReader || !state.introOn) return;
clearTimeout(introPvT);
introPvT = setTimeout(() => {
const total = introRun([...document.querySelectorAll(ITEM_SEL)]);
if (!overlay || !total) return;
overlay.classList.add('hmt-pv');
clearTimeout(introPvR);
introPvR = setTimeout(() => overlay.classList.remove('hmt-pv'), total + 400);
}, 300);
}
/* mode 'load': sembunyikan sejak awal, putar setelah daftar stabil.
* mode 'nav' : dipasang saat tombol Back/Forward (popstate); hanya bereaksi kalau situs merender ulang daftarnya. */
function introWatch(mode) {
const nav = mode === 'nav';
let t0 = Date.now(), finished = false, hidden = false, timer = 0, guard = 0, hdr = null, hdrAt = 0, sig = nav ? itemSig() : '';
const BLK = ['hmt-pre-nav', 'hmt-pre-navi', 'hmt-pre-bn', 'hmt-pre-bni', 'hmt-pre-blk'];
const dropBlk = () => BLK.forEach((c) => root.classList.remove(c));
const release = () => { finished = true; mo.disconnect(); clearTimeout(timer); clearTimeout(guard); root.classList.remove('hmt-pre'); dropBlk(); srQueue(); };
const hide = () => { if (hidden) return; hidden = true; root.classList.add('hmt-pre'); clearTimeout(guard); guard = setTimeout(() => { if (!finished) release(); }, INTRO.maxWait + 1500); };
const runHdr = () => { if (hdr || nav) return; hdr = introHeaders(); hdrAt = Date.now(); dropBlk(); }; // header + blok galeri
const imgsReady = (els) => els.every((c) => { const im = c.querySelector('img'); return !im || im.complete; });
const check = () => {
if (finished) return;
const elapsed = Date.now() - t0, timeout = elapsed > INTRO.maxWait;
const stkOk = stkReady || !state.stickyNav;
const items = [...document.querySelectorAll(ITEM_SEL)];
if (isGallery && !nav && !hdr && stkOk && elapsed > 500) runHdr(); // cover & info tidak menunggu thumbnail
// halaman galeri: tunggu thumbnail besar terbentuk dulu (kartu related bisa muncul lebih awal)
if (isGallery && !nav && state.thumbs && !timeout && !document.querySelector('.hm-th')) { timer = setTimeout(check, 150); return; }
if (!items.length) { if (timeout) { runHdr(); release(); } else timer = setTimeout(check, 150); return; }
if (!timeout && !(imgsReady(items.filter(introInView)) && stkOk)) { timer = setTimeout(check, 100); return; }
runHdr();
introPlay(items, hdr ? Math.max(0, hdr.offset - (Date.now() - hdrAt)) : 0);
release(); // animasi sudah terdaftar (fill backwards menahan opacity 0 selama jeda), aman membuka sembunyi
};
const mo = new MutationObserver(() => {
if (finished) return;
const s2 = itemSig();
if (s2 === sig) return;
sig = s2;
if (nav && !hidden) { hide(); t0 = Date.now(); } // daftar diganti situs -> sembunyikan seketika (sebelum paint)
clearTimeout(timer); timer = setTimeout(check, INTRO.settle); // daftar berubah -> tunggu stabil lagi
});
mo.observe(root, { childList: true, subtree: true });
if (nav) { guard = setTimeout(() => { if (!hidden) release(); }, 2500); return; } // tidak ada perubahan daftar -> tidak melakukan apa-apa
hide();
if (state.introNav && state.introNav !== 'none') root.classList.add(state.introNav === 'items' ? 'hmt-pre-navi' : 'hmt-pre-nav');
if (!isGallery && state.introBanner && state.introBanner !== 'none') root.classList.add(state.introBanner === 'items' ? 'hmt-pre-bni' : 'hmt-pre-bn');
if (isGallery && INTRO_FX[state.introBlock]) root.classList.add('hmt-pre-blk');
timer = setTimeout(check, INTRO.settle);
}
/* ====================================================================
* ANIMASI SAAT DI-SCROLL: kartu/thumbnail di bawah layar disembunyikan (data-hmt-sr),
* lalu dianimasikan sekali ketika masuk layar. Yang sudah terlihat saat halaman dibuka tidak disentuh.
* ==================================================================== */
let srSeen = new WeakSet(), srIO = null, srRaf = 0;
const srClear = () => document.querySelectorAll('[data-hmt-sr]').forEach((e) => e.removeAttribute('data-hmt-sr'));
function srReveal(entries) {
const hit = entries.filter((e) => e.isIntersecting)
.sort((a, b) => (a.boundingClientRect.top - b.boundingClientRect.top) || (a.boundingClientRect.left - b.boundingClientRect.left));
if (!hit.length) return;
const step = Math.min(+state.introStep || 0, 90), keys = Object.keys(INTRO_FX);
const st = state.introScrollStyle === 'same' ? state.introStyle : state.introScrollStyle;
hit.forEach((en, k) => {
const el = en.target;
if (srIO) srIO.unobserve(el);
const fx = INTRO_FX[st === 'mix' ? keys[Math.floor(Math.random() * keys.length)] : st] || INTRO_FX.rise;
try { el.animate(fx.kf, { duration: INTRO.dur * (+state.introSpeed || 1) * (fx.d || 1) * .85, delay: k * step, easing: fx.ease, fill: 'backwards' }); } catch (e) { /* tampil biasa */ }
el.removeAttribute('data-hmt-sr'); // animasi sudah terdaftar (fill backwards menahan opacity 0), aman dibuka
});
}
function srScan() {
srRaf = 0;
if (isReader || !state.introScroll || root.classList.contains('hmt-pre')) return; // intro awal masih jalan: dipanggil lagi saat selesai
try {
if (!srIO) srIO = new IntersectionObserver(srReveal, { rootMargin: '0px 0px -6% 0px', threshold: 0.01 });
document.querySelectorAll(ITEM_SEL).forEach((el) => {
if (srSeen.has(el)) return;
const r = el.getBoundingClientRect();
if (!r.width || !r.height) return; // sedang tersembunyi: cek lagi nanti
srSeen.add(el);
if (r.top >= innerHeight * .94) { el.setAttribute('data-hmt-sr', ''); srIO.observe(el); } // di bawah layar
});
} catch (e) { srClear(); } // apa pun yang gagal: jangan biarkan kartu tersembunyi
}
function srQueue() { if (!srRaf) srRaf = requestAnimationFrame(srScan); }
function srRefresh() {
if (state.introScroll) { srQueue(); return; }
if (srIO) { srIO.disconnect(); srIO = null; }
srClear(); srSeen = new WeakSet();
}
if (!isReader) {
new MutationObserver(srQueue).observe(root, { childList: true, subtree: true });
window.addEventListener('scroll', srQueue, { passive: true });
window.addEventListener('resize', srQueue);
srQueue();
}
if (!isReader && state.introOn) introWatch('load');
// tombol Back/Forward: halaman dipulihkan dari cache browser (bfcache) -> putar ulang animasinya
window.addEventListener('pageshow', (e) => {
if (!e.persisted || isReader || !state.introOn) return;
try { introRun([...document.querySelectorAll(ITEM_SEL)]); } catch (err) { /* abaikan */ }
});
// Back/Forward pada situs yang merender ulang daftar tanpa reload
window.addEventListener('popstate', () => { if (!isReader && state.introOn) introWatch('nav'); });
/* ====================================================================
* READER: WEBTOON
* ==================================================================== */
if (isReader) {
const waitFor = (cond, ms = 12000) => new Promise((res) => {
const t0 = Date.now();
(function tick() {
if (cond()) return res(true);
if (Date.now() - t0 > ms) return res(false);
setTimeout(tick, 150);
})();
});
function attach(img, f) {
const list = candidates(f);
let i = 0;
img.addEventListener('error', () => {
if (++i < list.length) {
img.src = list[i];
} else {
img.classList.add('wt-fail');
img.title = T('Gagal dimuat, klik untuk coba lagi');
console.warn('[HMT] semua URL gagal untuk gambar', img.dataset.idx, list);
}
});
img.addEventListener('click', () => {
if (!img.classList.contains('wt-fail')) return;
img.classList.remove('wt-fail');
i = 0;
img.removeAttribute('src');
requestAnimationFrame(() => { img.src = list[0]; });
});
if (list.length) img.src = list[0];
else img.classList.add('wt-fail');
}
let building = false;
async function buildWebtoon() {
if (building) return;
building = true;
try {
const ok = await waitFor(() => document.body && window.galleryinfo && window.galleryinfo.files &&
window.gg && typeof window.gg.m === 'function');
if (!ok) {
console.warn('[HMT] galleryinfo/gg tidak ditemukan, webtoon dibatalkan');
wtFailed = true; apply(); // kembali ke reader asli
return;
}
const files = window.galleryinfo.files;
const old = document.getElementById('wt-container');
if (old) old.remove();
const wrap = mk('div');
wrap.id = 'wt-container';
files.forEach((f, i) => {
const img = document.createElement('img');
img.loading = 'lazy';
img.decoding = 'async';
img.alt = '';
img.dataset.idx = i;
if (f.width && f.height) {
img.width = f.width; img.height = f.height;
img.style.aspectRatio = `${f.width} / ${f.height}`;
}
attach(img, f);
wrap.appendChild(img);
});
const anchor = document.getElementById('comicImages');
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(wrap, anchor);
else document.body.appendChild(wrap);
// counter halaman + sinkron dengan dropdown halaman
let counter = document.getElementById('wt-counter');
if (!counter) { counter = mk('div'); counter.id = 'wt-counter'; document.body.appendChild(counter); }
const sel = document.getElementById('single-page-select');
const imgs = [...wrap.children];
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
const idx = +e.target.dataset.idx;
counter.textContent = `${idx + 1} / ${imgs.length}`;
if (sel && sel.options.length === imgs.length) sel.selectedIndex = idx;
});
}, { rootMargin: '-45% 0px -45% 0px' });
imgs.forEach((im) => io.observe(im));
counter.textContent = `1 / ${imgs.length}`;
// dropdown halaman -> lompat ke gambar
if (sel && !sel.dataset.hmt) {
sel.dataset.hmt = '1';
sel.addEventListener('change', (e) => {
if (!window.__wtReady || !state.webtoon) return;
e.stopImmediatePropagation();
const target = imgs[sel.selectedIndex];
if (target) target.scrollIntoView({ block: 'start' });
}, true);
}
window.__wtReady = true;
wtFailed = false;
apply();
const hm = location.hash.match(/^#(\d+)/);
const target = hm && imgs[+hm[1] - 1];
if (target) target.scrollIntoView({ block: 'start' });
else window.scrollTo(0, 0);
} finally {
building = false;
}
}
// Keyboard: j / panah kiri = gambar berikutnya, k / panah kanan = sebelumnya
window.addEventListener('keydown', (e) => {
if (!window.__wtReady || !state.webtoon) return;
if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
const k = e.key;
if (!['ArrowLeft', 'ArrowRight', 'j', 'k', 'v', 'h'].includes(k)) return;
e.stopImmediatePropagation();
e.preventDefault();
const imgs = [...document.querySelectorAll('#wt-container img')];
const mid = window.innerHeight / 2;
let cur = imgs.findIndex((i) => i.getBoundingClientRect().bottom > mid);
if (cur < 0) cur = imgs.length - 1;
const next = (k === 'ArrowLeft' || k === 'j') ? cur + 1 : (k === 'ArrowRight' || k === 'k') ? cur - 1 : cur;
if (imgs[next]) imgs[next].scrollIntoView({ block: 'start' });
}, true);
buildWebtoonFn = buildWebtoon;
if (state.webtoon) buildWebtoon();
}
/* ====================================================================
* PANEL PILIH UNDUHAN: klik "Download" -> pilih gambar dulu -> ZIP
* (ZIP dibuat sendiri tanpa library; gambar webp/avif sudah terkompres
* jadi disimpan apa adanya / "store")
* ==================================================================== */
const CRC_T = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
return t;
})();
function crc32(u8) {
let c = 0xFFFFFFFF;
for (let i = 0; i < u8.length; i++) c = CRC_T[(c ^ u8[i]) & 255] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function makeZip(entries) { // entries: [{ name, blob, size, crc }]
const enc = new TextEncoder();
const d = new Date();
const dt = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
const dd = (((Math.max(d.getFullYear(), 1980) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
const parts = [], central = [];
let off = 0;
for (const e of entries) {
const nm = enc.encode(e.name);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true); lh.setUint16(4, 20, true); lh.setUint16(6, 0x0800, true); lh.setUint16(8, 0, true);
lh.setUint16(10, dt, true); lh.setUint16(12, dd, true);
lh.setUint32(14, e.crc, true); lh.setUint32(18, e.size, true); lh.setUint32(22, e.size, true);
lh.setUint16(26, nm.length, true); lh.setUint16(28, 0, true);
parts.push(lh.buffer, nm, e.blob);
const ch = new DataView(new ArrayBuffer(46));
ch.setUint32(0, 0x02014b50, true); ch.setUint16(4, 20, true); ch.setUint16(6, 20, true);
ch.setUint16(8, 0x0800, true); ch.setUint16(10, 0, true);
ch.setUint16(12, dt, true); ch.setUint16(14, dd, true);
ch.setUint32(16, e.crc, true); ch.setUint32(20, e.size, true); ch.setUint32(24, e.size, true);
ch.setUint16(28, nm.length, true); ch.setUint32(42, off, true);
central.push(ch.buffer, nm);
off += 30 + nm.length + e.size;
}
const cdSize = central.reduce((s, p) => s + p.byteLength, 0);
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true);
end.setUint16(8, entries.length, true); end.setUint16(10, entries.length, true);
end.setUint32(12, cdSize, true); end.setUint32(16, off, true);
return new Blob([...parts, ...central, end.buffer], { type: 'application/zip' });
}
// Konversi gambar (webp/avif) -> JPG lewat canvas. Latar putih untuk transparansi.
// Mengembalikan { blob, w, h }.
async function toJpg(buf, q) {
const bmp = await createImageBitmap(new Blob([buf]));
const w = bmp.width, h = bmp.height;
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h);
ctx.drawImage(bmp, 0, 0);
if (bmp.close) bmp.close();
const blob = await new Promise((res, rej) => cv.toBlob((x) => (x ? res(x) : rej(new Error('toBlob gagal'))), 'image/jpeg', q / 100));
cv.width = cv.height = 0;
return { blob, w, h };
}
// PDF sederhana: satu gambar JPEG (DCTDecode) per halaman, ukuran halaman = ukuran gambar.
function makePdf(pages) { // pages: [{ blob, size, w, h }]
const enc = new TextEncoder();
const parts = [];
let off = 0;
const push = (x) => { parts.push(x); off += x.byteLength !== undefined ? x.byteLength : x.size; };
const str = (t) => push(enc.encode(t));
const n = pages.length;
const xref = new Array(3 * n + 3).fill(0);
const obj = (id, fn) => { xref[id] = off; str(id + ' 0 obj\n'); fn(); str('endobj\n'); };
str('%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n');
obj(1, () => str('<< /Type /Catalog /Pages 2 0 R >>\n'));
obj(2, () => str('<< /Type /Pages /Count ' + n + ' /Kids [' + pages.map((_, i) => (5 + 3 * i) + ' 0 R').join(' ') + '] >>\n'));
pages.forEach((pg, i) => {
const img = 3 + 3 * i, con = 4 + 3 * i, page = 5 + 3 * i;
const pw = Math.round(pg.w * 0.75 * 100) / 100, ph = Math.round(pg.h * 0.75 * 100) / 100;
obj(img, () => {
str('<< /Type /XObject /Subtype /Image /Width ' + pg.w + ' /Height ' + pg.h +
' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ' + pg.size + ' >>\nstream\n');
push(pg.blob);
str('\nendstream\n');
});
const content = 'q ' + pw + ' 0 0 ' + ph + ' 0 0 cm /Im0 Do Q';
obj(con, () => str('<< /Length ' + content.length + ' >>\nstream\n' + content + '\nendstream\n'));
obj(page, () => str('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + pw + ' ' + ph + '] /Resources << /XObject << /Im0 ' + img +
' 0 R >> >> /Contents ' + con + ' 0 R >>\n'));
});
const xrefOff = off;
let x = 'xref\n0 ' + (3 * n + 3) + '\n0000000000 65535 f \n';
for (let id = 1; id < 3 * n + 3; id++) x += String(xref[id]).padStart(10, '0') + ' 00000 n \n';
x += 'trailer\n<< /Size ' + (3 * n + 3) + ' /Root 1 0 R >>\nstartxref\n' + xrefOff + '\n%%EOF\n';
str(x);
return new Blob(parts, { type: 'application/pdf' });
}
/* ====================================================================
* Notifikasi (toast) & dialog konfirmasi buatan sendiri
* (pengganti alert/confirm bawaan browser)
* ==================================================================== */
let modalOpen = false;
function toast(text, opt) {
opt = opt || {};
const type = opt.type || 'info';
let host = document.getElementById('hmt-toasts');
if (!host) { host = mk('div'); host.id = 'hmt-toasts'; }
if (!host.isConnected) document.body.appendChild(host);
const t = mk('div', 'hmt-toast ' + type);
t.appendChild(mk('span', 't-ico', type === 'ok' ? '✓' : type === 'err' ? '✕' : 'ℹ'));
t.appendChild(mk('span', 't-msg', T(text)));
const kill = () => {
if (!t.isConnected) return;
t.classList.add('out');
setTimeout(() => t.remove(), 200);
};
if (opt.action) {
const b = mk('button', 'pri', T(opt.action.label)); b.type = 'button';
b.addEventListener('click', () => { kill(); try { opt.action.fn(); } catch (e) { console.warn('[HMT]', e); } });
t.appendChild(b);
}
const x = mk('button', 'x', '✕'); x.type = 'button'; x.addEventListener('click', kill);
t.appendChild(x);
host.appendChild(t);
while (host.children.length > 4) host.firstChild.remove();
const ms = opt.ms != null ? opt.ms : (opt.action ? 12000 : type === 'err' ? 8000 : 4500);
if (ms > 0) setTimeout(kill, ms);
return kill;
}
function askConfirm(o) {
return new Promise((resolve) => {
modalOpen = true;
const ov = mk('div'); ov.id = 'hmt-modal';
const box = mk('div', 'm-box');
box.appendChild(mk('div', 'm-title', T(o.title || 'Konfirmasi')));
if (o.text) box.appendChild(mk('div', 'm-text', T(o.text)));
const act = mk('div', 'm-act');
const bNo = mk('button', '', T(o.cancel || 'Batal')); bNo.type = 'button';
const bOk = mk('button', o.danger ? 'danger' : 'pri', T(o.ok || 'OK')); bOk.type = 'button';
act.append(bNo, bOk); box.appendChild(act); ov.appendChild(box);
const done = (v) => {
window.removeEventListener('keydown', onKey, true);
ov.remove(); setTimeout(() => { modalOpen = false; }, 0); resolve(v);
};
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); done(false); }
else if (e.key === 'Enter') { e.preventDefault(); e.stopImmediatePropagation(); done(true); }
};
window.addEventListener('keydown', onKey, true);
bNo.addEventListener('click', () => done(false));
bOk.addEventListener('click', () => done(true));
ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });
document.body.appendChild(ov);
bOk.focus && setTimeout(() => { try { bOk.focus(); } catch (e) { /* abaikan */ } }, 0);
});
}
/* ====================================================================
* Jendela: geser (drag) & ubah ukuran (resize), posisi diingat
* ==================================================================== */
function fixPos(box) {
const b = box.getBoundingClientRect();
box.style.left = b.left + 'px'; box.style.top = b.top + 'px';
box.style.right = 'auto'; box.style.bottom = 'auto';
return b;
}
function keepInView(box) {
const b = box.getBoundingClientRect();
if (!b.width) return;
const x = Math.min(Math.max(b.left, 80 - b.width), innerWidth - 80);
const y = Math.min(Math.max(b.top, 0), innerHeight - 40);
if (x !== b.left || y !== b.top) {
box.style.left = x + 'px'; box.style.top = y + 'px'; box.style.right = 'auto'; box.style.bottom = 'auto';
}
}
function saveRect(box, key) {
const b = box.getBoundingClientRect();
set(key, { x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) });
}
function attachDrag(box, handle, key) {
handle.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || e.target.closest('button, input')) return;
e.preventDefault();
const b = fixPos(box);
const sx = e.clientX, sy = e.clientY, ox = b.left, oy = b.top, w = b.width;
try { handle.setPointerCapture(e.pointerId); } catch (err) { /* abaikan */ }
const mv = (ev) => {
box.style.left = Math.min(Math.max(ox + ev.clientX - sx, 80 - w), innerWidth - 80) + 'px';
box.style.top = Math.min(Math.max(oy + ev.clientY - sy, 0), innerHeight - 40) + 'px';
};
const up = () => {
handle.removeEventListener('pointermove', mv);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
saveRect(box, key);
};
handle.addEventListener('pointermove', mv);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
});
}
function attachResize(box, key, minW, minH) {
['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].forEach((d) => {
const h = mk('div', 'dl-rs dl-rs-' + d);
box.appendChild(h);
h.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
const b = fixPos(box);
const sx = e.clientX, sy = e.clientY;
try { h.setPointerCapture(e.pointerId); } catch (err) { /* abaikan */ }
const mv = (ev) => {
const dx = ev.clientX - sx, dy = ev.clientY - sy;
let w = b.width, hh = b.height, x = b.left, y = b.top;
if (d.includes('e')) w = b.width + dx;
if (d.includes('w')) w = b.width - dx;
if (d.includes('s')) hh = b.height + dy;
if (d.includes('n')) hh = b.height - dy;
w = Math.min(Math.max(w, minW), innerWidth);
hh = Math.min(Math.max(hh, minH), innerHeight);
if (d.includes('w')) x = b.left + b.width - w;
if (d.includes('n')) {
y = b.top + b.height - hh;
if (y < 0) { hh += y; y = 0; }
}
box.style.left = x + 'px'; box.style.top = y + 'px';
box.style.width = w + 'px'; box.style.height = hh + 'px';
};
const up = () => {
h.removeEventListener('pointermove', mv);
h.removeEventListener('pointerup', up);
h.removeEventListener('pointercancel', up);
saveRect(box, key);
};
h.addEventListener('pointermove', mv);
h.addEventListener('pointerup', up);
h.addEventListener('pointercancel', up);
});
});
}
/* ====================================================================
* Simpan ke folder (File System Access API, Chrome/Edge) + penyimpanan
* antrian di IndexedDB (dibagi antar tab & antar halaman hitomi.la)
* ==================================================================== */
const HAS_DIR = typeof window.showDirectoryPicker === 'function';
let dirHandle = null; // folder tujuan terakhir (disimpan di IndexedDB supaya tidak perlu dipilih ulang)
let dbp = null;
const idbOpen = () => dbp || (dbp = new Promise((res, rej) => {
const r = indexedDB.open('hmt', 2);
r.onupgradeneeded = () => {
const db = r.result;
if (!db.objectStoreNames.contains('h')) db.createObjectStore('h');
if (!db.objectStoreNames.contains('jobs')) db.createObjectStore('jobs', { keyPath: 'id' });
if (!db.objectStoreNames.contains('data')) db.createObjectStore('data');
};
r.onsuccess = () => {
const db = r.result;
db.onversionchange = () => { db.close(); dbp = null; };
res(db);
};
r.onerror = () => { dbp = null; rej(r.error); };
}));
async function idb(store, mode, fn) {
const db = await idbOpen();
return new Promise((res, rej) => {
const t = db.transaction(store, mode);
let out;
const r = fn(t.objectStore(store));
if (r) r.onsuccess = () => { out = r.result; };
t.oncomplete = () => res(out);
t.onerror = () => rej(t.error);
t.onabort = () => rej(t.error);
});
}
const idbGet = (k) => idb('h', 'readonly', (s) => s.get(k));
const idbSet = (k, v) => idb('h', 'readwrite', (s) => s.put(v, k));
const jobPut = (j) => idb('jobs', 'readwrite', (s) => s.put(j));
const jobGet = (id) => idb('jobs', 'readonly', (s) => s.get(id));
const jobAll = () => idb('jobs', 'readonly', (s) => s.getAll());
const jobDel = (id) => idb('jobs', 'readwrite', (s) => s.delete(id));
const dKey = (id, k) => id + ':' + k;
const dataPut = (id, k, v) => idb('data', 'readwrite', (s) => s.put(v, dKey(id, k)));
const dataGet = (id, k) => idb('data', 'readonly', (s) => s.get(dKey(id, k)));
const dataKeys = (id) => idb('data', 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(id + ':', id + ';')));
const dataClear = (id) => idb('data', 'readwrite', (s) => s.delete(IDBKeyRange.bound(id + ':', id + ';')));
// Ubah satu job secara atomik. onlyIfRunning: tidak menulis kalau job sudah dibatalkan/dihapus.
async function jobPatch(id, patch, onlyIfRunning) {
const db = await idbOpen();
return new Promise((res, rej) => {
const t = db.transaction('jobs', 'readwrite');
const s = t.objectStore('jobs');
let result = null;
const g = s.get(id);
g.onsuccess = () => {
const cur = g.result;
if (!cur) return;
if (onlyIfRunning && cur.status !== 'running') { result = cur; return; }
const nx = Object.assign(cur, typeof patch === 'function' ? patch(cur) : patch);
s.put(nx); result = nx;
};
t.oncomplete = () => res(result);
t.onerror = () => rej(t.error);
t.onabort = () => rej(t.error);
});
}
// Chrome menolak folder sistem (Downloads/Documents/Desktop langsung, drive root, dll) dengan dialog bawaan
// yang tidak bisa diganti. Jadi sebelum picker dibuka, kita beri panduan dengan dialog buatan sendiri.
async function pickDir(why) {
const ok = await askConfirm({
title: 'Pilih folder tujuan',
text: (why ? T(why) + ' ' : '') + T('Pilih atau buat SUBFOLDER sendiri (mis. Documents\\Hitomi). Browser menolak folder sistem seperti Downloads, Documents, atau Desktop langsung.'),
ok: 'Pilih folder', cancel: 'Batal',
});
if (!ok) { const e = new Error('dibatalkan'); e.name = 'AbortError'; throw e; }
const h = await window.showDirectoryPicker({ id: 'hmt-dl', mode: 'readwrite' });
dirHandle = h;
try { await idbSet('dir', h); } catch (e) { /* tidak fatal */ }
return h;
}
async function ensureDir() { // panggil langsung dari klik pengguna (perlu user gesture)
if (!dirHandle) { try { dirHandle = await idbGet('dir'); } catch (e) { /* abaikan */ } }
if (dirHandle) {
try {
let perm = await dirHandle.queryPermission({ mode: 'readwrite' });
if (perm !== 'granted') perm = await dirHandle.requestPermission({ mode: 'readwrite' });
if (perm === 'granted') return dirHandle;
} catch (e) { /* handle lama tidak valid -> pilih ulang */ }
}
return pickDir(dirHandle ? 'Izin ke folder sebelumnya tidak tersedia.' : 'Belum ada folder tujuan.');
}
async function dirIfGranted() { // tanpa gesture: hanya kalau izin sudah ada
if (!dirHandle) { try { dirHandle = await idbGet('dir'); } catch (e) { /* abaikan */ } }
if (!dirHandle) return null;
try { if ((await dirHandle.queryPermission({ mode: 'readwrite' })) === 'granted') return dirHandle; } catch (e) { /* abaikan */ }
return null;
}
async function writeTo(dir, name, blob) {
const fh = await dir.getFileHandle(name, { create: true });
const w = await fh.createWritable();
try { await w.write(blob); } finally { await w.close(); }
}
async function fetchUrls(urls) {
let last = null;
for (const u of urls) {
try {
const r = await fetch(u, { credentials: 'omit', signal: window.AbortSignal && AbortSignal.timeout ? AbortSignal.timeout(60000) : undefined });
if (r.ok) return { buf: await r.arrayBuffer(), url: u };
last = new Error('HTTP ' + r.status);
} catch (e) { last = e; }
}
throw last || new Error('tidak ada URL');
}
/* ====================================================================
* ANTRIAN UNDUHAN: berjalan di latar belakang, dilanjutkan otomatis
* di halaman/tab mana pun. Satu "runner" aktif (Web Locks). Kalau halaman
* yang menjalankannya ditinggalkan, halaman berikutnya meneruskan dari
* gambar yang belum selesai (gambar yang sudah jadi tersimpan di IndexedDB).
* ==================================================================== */
let leaving = false;
window.addEventListener('beforeunload', () => { leaving = true; setTimeout(() => { leaving = false; }, 2500); });
window.addEventListener('pagehide', () => { leaving = true; });
window.addEventListener('pageshow', () => { leaving = false; });
const isActive = (j) => j.status === 'queued' || j.status === 'running';
let runnerOn = false;
async function runJob(j) {
await jobPatch(j.id, { status: 'running' });
const have = new Set((await dataKeys(j.id)).map((k) => +String(k).split(':')[1]));
const failedSet = new Set(j.failed || []);
const pending = [];
j.items.forEach((it, k) => { if (!have.has(k) && !failedSet.has(k)) pending.push(k); });
await jobPatch(j.id, { done: have.size + failedSet.size });
let next = 0, alive = true;
const worker = async () => {
while (alive && !leaving && next < pending.length) {
const k = pending[next++], it = j.items[k];
try {
const { buf, url } = await fetchUrls(it.urls);
let ext = (url.match(/\.(\w+)(?:\?|$)/) || [])[1] || 'webp';
let rec, kept = false;
if (j.out === 'pdf') {
const r = await toJpg(buf, j.q);
rec = { blob: r.blob, size: r.blob.size, w: r.w, h: r.h };
} else {
let data = buf;
if (j.asJpg && !/^jpe?g$/i.test(ext)) {
try { data = await (await toJpg(buf, j.q)).blob.arrayBuffer(); ext = 'jpg'; }
catch (e) { kept = true; console.warn('[HMT] konversi JPG gagal, pakai file asli: hal.', it.i + 1, e); }
}
rec = { name: String(it.i + 1).padStart(j.pad, '0') + '.' + ext, blob: new Blob([data]), size: data.byteLength, crc: crc32(new Uint8Array(data)) };
}
await dataPut(j.id, k, rec);
const cur = await jobPatch(j.id, (c) => ({ done: c.done + 1, kept: (c.kept || 0) + (kept ? 1 : 0) }), true);
if (!cur || cur.status !== 'running') alive = false;
} catch (e) {
if (leaving) return; // halaman ditinggalkan: jangan dianggap gagal, halaman berikut melanjutkan
console.warn('[HMT] gagal memproses gambar', it.i + 1, e);
const cur = await jobPatch(j.id, (c) => ({ done: c.done + 1, failed: (c.failed || []).concat(k) }), true);
if (!cur || cur.status !== 'running') alive = false;
}
}
};
await Promise.all(Array.from({ length: Math.min(4, Math.max(1, pending.length)) }, worker));
if (leaving) return;
const cur = await jobGet(j.id);
if (!cur || cur.status !== 'running') { // dibatalkan / dihapus
await dataClear(j.id);
if (cur && cur.status === 'canceled') await jobDel(j.id);
return;
}
await finalizeJob(j.id, null);
}
// Susun ZIP/PDF/gambar dari data di IndexedDB. gestureDir = folder dari klik pengguna (opsional).
async function finalizeJob(id, gestureDir) {
try {
const j = await jobGet(id);
if (!j) return;
const keys = (await dataKeys(id)).map((k) => +String(k).split(':')[1]).sort((a, b) => a - b);
if (!keys.length) { await jobPatch(id, { status: 'error', ended: Date.now(), err: 'Semua gambar gagal diproses (lihat console F12)' }); return; }
let dir = null;
if (HAS_DIR && (j.dest === 'dir' || j.out === 'img')) {
dir = gestureDir || await dirIfGranted();
if (!dir) { await jobPatch(id, { status: 'ready' }); return; } // menunggu klik "Simpan" (butuh izin folder)
}
const recs = [];
for (const k of keys) recs.push(await dataGet(id, k));
if (j.out === 'img') {
const sub = await dir.getDirectoryHandle(j.subName, { create: true });
for (const r of recs) await writeTo(sub, r.name, r.blob);
} else {
const blob = j.out === 'pdf' ? makePdf(recs) : makeZip(recs);
if (dir) await writeTo(dir, j.fname, blob);
else {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = j.fname;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 120000);
}
}
await dataClear(id);
await jobPatch(id, { status: 'done', ended: Date.now(), dirName: dir ? dir.name : '' });
markDone(j.gid);
} catch (e) {
console.warn('[HMT] finalisasi gagal', e);
await jobPatch(id, { status: 'error', ended: Date.now(), err: 'Gagal menyimpan: ' + ((e && e.message) || e) });
}
}
async function runLoop() {
for (;;) {
if (leaving) return;
const jobs = (await jobAll()).sort((a, b) => a.created - b.created);
const j = jobs.find((x) => x.status === 'running') || jobs.find((x) => x.status === 'queued');
if (!j) return;
try { await runJob(j); }
catch (e) {
console.warn('[HMT] job error', e);
await jobPatch(j.id, { status: 'error', ended: Date.now(), err: 'Error: ' + ((e && e.message) || e) });
}
}
}
function kick() {
if (runnerOn) return;
const go = async () => { runnerOn = true; try { await runLoop(); } finally { runnerOn = false; } };
if (navigator.locks && navigator.locks.request) {
navigator.locks.request('hmt-dl-runner', { ifAvailable: true }, async (lock) => { if (lock) await go(); }).catch(() => {});
} else go();
}
async function cancelJob(id) {
await jobPatch(id, { status: 'canceled' });
await dataClear(id);
setTimeout(() => { jobDel(id).catch(() => {}); }, 1500);
dmTick();
}
async function removeJob(id) {
await dataClear(id); await jobDel(id); dmTick();
}
async function saveReady(id) { // dipanggil dari klik (gesture) -> minta izin folder
try {
const dir = await ensureDir();
await finalizeJob(id, dir);
dmTick();
} catch (e) {
if (e && e.name === 'AbortError') toast('Pemilihan folder dibatalkan');
else toast(T('Tidak bisa mengakses folder: {0}', (e && e.message) || e), { type: 'err' });
}
}
/* ====================================================================
* Widget antrian (kanan bawah, bisa digeser/ciut) + notifikasi status
* ==================================================================== */
let dmEl = null, dmBody = null, dmTitle = null, dmPlaced = false, dmFirst = true;
const dmRows = new Map();
const dmSeen = new Map();
function jobText(j) {
const n = j.items.length;
const nf = j.failed && j.failed.length ? T(' (gagal: {0})', j.failed.length) : '';
switch (j.status) {
case 'queued': return T('Menunggu antrian · {0} gambar', n);
case 'running':
return j.done >= n ? T('Menyusun file…')
: T('Mengunduh {0} / {1}', j.done, n) + nf + (j.out === 'pdf' || j.asJpg ? T(' · konversi JPG') : '');
case 'ready': return T('Siap — klik Simpan untuk menulis ke folder') + nf;
case 'done': return T('Selesai ✓ → {0}', outName(j.out)) + (j.dirName ? T(' di folder "{0}"', j.dirName) : '') + nf;
case 'error': return tErr(j.err || 'Gagal');
default: return j.status;
}
}
function ensureDm() {
if (dmEl) { if (!dmEl.isConnected) document.body.appendChild(dmEl); return dmEl; }
dmEl = mk('div', 'hide'); dmEl.id = 'hmt-dm';
const head = mk('div', 'dm-head');
dmTitle = mk('span', '', T('Unduhan'));
const hb = mk('div', 'dm-hb');
const bClr = mk('button', '', T('Bersihkan')); bClr.type = 'button'; bClr.title = T('Hapus yang sudah selesai / gagal');
const bMin = mk('button', '', '—'); bMin.type = 'button'; bMin.title = T('Ciutkan / buka');
bClr.addEventListener('click', async () => {
const js = await jobAll();
for (const j of js) if (j.status === 'done' || j.status === 'error') await removeJob(j.id);
});
const applyMin = () => dmEl.classList.toggle('min', !!get('dmMin', false));
bMin.addEventListener('click', () => { set('dmMin', !get('dmMin', false)); applyMin(); });
hb.append(bClr, bMin);
head.append(dmTitle, hb);
dmBody = mk('div', 'dm-body');
dmEl.append(head, dmBody);
applyMin();
document.body.appendChild(dmEl);
attachDrag(dmEl, head, 'dmRect');
window.addEventListener('resize', () => keepInView(dmEl));
return dmEl;
}
function buildRow(j) {
const el = mk('div', 'dm-job');
const t = mk('div', 'dm-t', j.title); t.title = j.title;
const s = mk('div', 'dm-s');
const track = mk('div', 'dm-track'); const fill = mk('div', 'dm-fill'); track.appendChild(fill);
const a = mk('div', 'dm-a');
el.append(t, s, track, a);
return { el, s, fill, a, status: null };
}
function setActions(row, j) {
row.a.textContent = '';
const btn = (label, cls, fn) => { const b = mk('button', cls || '', T(label)); b.type = 'button'; b.addEventListener('click', fn); row.a.appendChild(b); return b; };
if (isActive(j)) {
btn('Batal', '', async () => {
const ok = await askConfirm({ title: 'Batalkan unduhan?', text: j.title, ok: 'Batalkan', cancel: 'Lanjutkan', danger: true });
if (ok) cancelJob(j.id);
});
} else if (j.status === 'ready') {
btn('Simpan', 'pri', () => saveReady(j.id));
btn('Hapus', '', () => removeJob(j.id));
} else {
btn('Hapus', '', () => removeJob(j.id));
}
}
function renderDm(jobs) {
const vis = jobs.filter((j) => j.status !== 'canceled');
if (!vis.length && !dmEl) return;
ensureDm();
dmEl.classList.toggle('hide', !vis.length);
if (vis.length && !dmPlaced) {
dmPlaced = true;
const r = get('dmRect', null);
if (r && typeof r.x === 'number') {
dmEl.style.left = r.x + 'px'; dmEl.style.top = r.y + 'px'; dmEl.style.right = 'auto'; dmEl.style.bottom = 'auto';
}
keepInView(dmEl);
}
const act = vis.filter(isActive).length;
dmTitle.textContent = T('Unduhan') + (act ? ' · ' + T('{0} berjalan', act) : '');
for (const [id, row] of dmRows) if (!vis.some((j) => j.id === id)) { row.el.remove(); dmRows.delete(id); }
vis.forEach((j) => {
let row = dmRows.get(j.id);
if (!row) { row = buildRow(j); dmRows.set(j.id, row); dmBody.appendChild(row.el); }
if (row.status !== j.status) { row.status = j.status; row.el.className = 'dm-job ' + j.status; setActions(row, j); }
row.s.textContent = jobText(j);
const n = j.items.length || 1;
row.fill.style.width = (j.status === 'done' || j.status === 'ready' ? 100 : Math.round(Math.min(j.done, n) / n * 100)) + '%';
});
}
function notifyChanges(jobs) {
jobs.forEach((j) => {
const prev = dmSeen.get(j.id);
dmSeen.set(j.id, j.status);
if (j.status === 'done' && prev !== j.status) markCards();
if (dmFirst || prev === j.status || document.visibilityState !== 'visible') return;
if (j.status === 'done') toast(T('Selesai: {0} → {1}', j.title, outName(j.out)), { type: 'ok' });
else if (j.status === 'ready') toast(T('Unduhan selesai, tinggal disimpan ke folder: {0}', j.title), { action: { label: 'Simpan', fn: () => saveReady(j.id) } });
else if (j.status === 'error') toast(j.title + ' — ' + tErr(j.err || 'gagal'), { type: 'err' });
});
}
let ticking = false;
async function dmTick() {
if (ticking) return;
ticking = true;
try {
const jobs = (await jobAll()).sort((a, b) => a.created - b.created);
for (const j of jobs) { // bersihkan yang sudah selesai > 60 detik
if (j.status === 'done' && Date.now() - (j.ended || 0) > 60000) { await removeJob(j.id); }
}
const live = jobs.filter((j) => !(j.status === 'done' && Date.now() - (j.ended || 0) > 60000));
renderDm(live);
notifyChanges(live);
dmFirst = false;
if (live.some(isActive) && !runnerOn) kick();
} catch (e) { /* IndexedDB tidak tersedia: abaikan */ }
ticking = false;
}
function dmStart() { dmTick(); setInterval(dmTick, 1200); }
/* ====================================================================
* Panel pilih gambar (bisa digeser & di-resize). Tombol Unduh hanya
* memasukkan pekerjaan ke antrian; panel langsung ditutup.
* ==================================================================== */
let dlEl = null, dlCleanup = null;
function closeDl() {
if (dlCleanup) { dlCleanup(); dlCleanup = null; }
if (dlEl) { dlEl.remove(); dlEl = null; }
}
function openDownloadPanel() {
if (dlEl) return;
const info = window.galleryinfo;
const files = info.files;
const total = files.length;
const sel = new Set(files.map((_, i) => i));
const rawTitle = String(info.title || document.title || 'hitomi').replace(/\s+/g, ' ').trim();
const safeTitle = rawTitle.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'hitomi';
const subName = safeTitle + (info.id ? ' [' + info.id + ']' : '');
const zipName = subName + '.zip';
const doneAt = info.id ? doneMap()[String(info.id)] : 0;
if (state.dlMark && doneAt) {
toast(T('Galeri ini sudah pernah diunduh ({0}).', new Date(doneAt).toLocaleDateString(state.lang === 'en' ? 'en-US' : 'id-ID')),
{ action: { label: 'Hapus tanda', fn: () => unmarkDone(String(info.id)) } });
}
dlEl = mk('div'); dlEl.id = 'hmt-dl';
const box = mk('div', 'dl-box');
box.style.setProperty('--dl-th', state.dlThumb + 'px');
dlEl.appendChild(box);
const head = mk('div', 'dl-head');
const hl = mk('div'); hl.style.minWidth = '0';
hl.appendChild(mk('div', 'dl-title', T('Pilih gambar untuk diunduh')));
hl.appendChild(mk('div', 'dl-sub', rawTitle));
const hb = mk('div', 'dl-hb');
const bMin = mk('button', 'dl-x', '—'); bMin.type = 'button'; bMin.title = T('Ciutkan / buka');
bMin.addEventListener('click', () => box.classList.toggle('min'));
const x = mk('button', 'dl-x', '✕'); x.type = 'button'; x.title = T('Tutup (Esc)');
x.addEventListener('click', closeDl);
hb.append(bMin, x);
head.append(hl, hb);
const bar = mk('div', 'dl-bar');
const bAll = mk('button', 'dl-btn', T('Pilih semua'));
const bNone = mk('button', 'dl-btn', T('Kosongkan'));
const bInv = mk('button', 'dl-btn', T('Balik pilihan'));
[bAll, bNone, bInv].forEach((b) => { b.type = 'button'; });
const hint = mk('span', 'dl-hint', T('Klik = pilih/batal · Shift+klik = rentang'));
const zBox = mk('div', 'dl-q');
const zIn = document.createElement('input');
zIn.type = 'range'; zIn.min = 90; zIn.max = 320; zIn.step = 10; zIn.value = state.dlThumb; zIn.title = T('Ukuran thumbnail');
zBox.append(mk('span', 'dl-lbl', T('Ukuran')), zIn);
zIn.addEventListener('input', () => { state.dlThumb = +zIn.value; set('dlThumb', state.dlThumb); box.style.setProperty('--dl-th', state.dlThumb + 'px'); });
const count = mk('span', 'dl-count');
bar.append(bAll, bNone, bInv, hint, zBox, count);
// ---- baris opsi: Hasil (ZIP/PDF/Gambar), Format (Asli/JPG), Simpan ke (Unduhan/Folder) ----
if (!HAS_DIR) {
if (state.dlDest === 'dir') state.dlDest = 'dl';
if (state.dlOut === 'img') state.dlOut = 'zip';
}
const bar2 = mk('div', 'dl-bar');
const mkSeg = (items, key, onSet) => {
const wrap = mk('div', 'dl-seg');
const btns = items.map(([v, t]) => {
const b = mk('button', '', T(t)); b.type = 'button'; b.dataset.v = v;
b.addEventListener('click', () => { state[key] = v; set(key, v); if (onSet) onSet(v); syncOpts(); });
wrap.appendChild(b); return b;
});
return { wrap, btns };
};
const outSeg = mkSeg([['zip', 'ZIP'], ['pdf', 'PDF']].concat(HAS_DIR ? [['img', 'Gambar']] : []), 'dlOut',
(v) => { if (v === 'img') { state.dlDest = 'dir'; set('dlDest', 'dir'); } });
const fmtLbl = mk('span', 'dl-lbl', T('Format'));
const fmtSeg = mkSeg([['orig', 'Asli'], ['jpg', 'JPG']], 'dlFmt');
const qBox = mk('div', 'dl-q');
const qIn = document.createElement('input');
qIn.type = 'range'; qIn.min = 50; qIn.max = 100; qIn.step = 5; qIn.title = T('Kualitas JPG');
const qVal = mk('span');
qBox.append(qIn, qVal);
qIn.addEventListener('input', () => { state.dlQ = +qIn.value; set('dlQ', state.dlQ); qVal.textContent = state.dlQ + '%'; });
const destLbl = mk('span', 'dl-lbl', T('Simpan ke'));
const destSeg = mkSeg([['dl', 'Unduhan']].concat(HAS_DIR ? [['dir', 'Folder']] : []), 'dlDest',
(v) => { if (v === 'dl' && state.dlOut === 'img') { state.dlOut = 'zip'; set('dlOut', 'zip'); } });
const bFolder = mk('button', 'dl-btn dl-folder'); bFolder.type = 'button';
bFolder.addEventListener('click', async () => {
try { await pickDir(); } catch (e) { if (e && e.name !== 'AbortError') console.warn('[HMT] pilih folder gagal', e); }
syncOpts();
});
bar2.append(mk('span', 'dl-lbl', T('Hasil')), outSeg.wrap, fmtLbl, fmtSeg.wrap, qBox, destLbl, destSeg.wrap, bFolder);
const syncOpts = () => {
const pdf = state.dlOut === 'pdf';
outSeg.btns.forEach((b) => b.classList.toggle('on', b.dataset.v === state.dlOut));
fmtSeg.btns.forEach((b) => b.classList.toggle('on', b.dataset.v === state.dlFmt));
destSeg.btns.forEach((b) => b.classList.toggle('on', b.dataset.v === state.dlDest));
fmtLbl.classList.toggle('dl-hide', pdf); fmtSeg.wrap.classList.toggle('dl-hide', pdf); // PDF selalu JPEG
qBox.classList.toggle('dl-hide', !(pdf || state.dlFmt === 'jpg'));
destLbl.classList.toggle('dl-hide', !HAS_DIR); destSeg.wrap.classList.toggle('dl-hide', !HAS_DIR);
bFolder.classList.toggle('dl-hide', state.dlDest !== 'dir');
bFolder.textContent = dirHandle ? '📁 ' + dirHandle.name : T('Pilih folder…');
qIn.value = state.dlQ; qVal.textContent = state.dlQ + '%';
refresh();
};
const grid = mk('div', 'dl-grid');
const cells = [];
const th = thumbHost && thumbHost.isConnected ? thumbHost.querySelectorAll('.hm-th img') : null;
files.forEach((f, i) => {
const item = mk('div', 'dl-item');
const c = mk('div', 'dl-cell on');
const img = document.createElement('img');
img.loading = 'lazy'; img.decoding = 'async'; img.alt = '';
const first = th && th[i] ? (th[i].currentSrc || th[i].getAttribute('src') || '') : '';
const list = [first, ...candidates(f)].filter(Boolean);
let k = 0;
img.addEventListener('error', () => { if (++k < list.length) img.src = list[k]; });
if (list.length) img.src = list[0];
c.append(img, mk('span', 'dl-n', String(i + 1)), mk('span', 'dl-ck', '✓'));
item.dataset.i = String(i);
item.appendChild(c);
grid.appendChild(item);
cells.push(c);
});
const foot = mk('div', 'dl-foot');
const stat = mk('div', 'dl-stat');
const bCancel = mk('button', 'dl-btn', T('Tutup')); bCancel.type = 'button';
const bGo = mk('button', 'dl-btn pri'); bGo.type = 'button';
foot.append(stat, bCancel, bGo);
box.append(head, bar, bar2, grid, foot);
let last = null;
const paint = (i) => {
const on = sel.has(i);
cells[i].classList.toggle('on', on);
cells[i].classList.toggle('off', !on);
cells[i].querySelector('.dl-ck').textContent = on ? '✓' : '';
};
const refresh = () => {
count.textContent = T('{0} / {1} dipilih', sel.size, total);
bGo.textContent = T(state.dlDest === 'dir' ? 'Simpan {0} ({1})' : 'Unduh {0} ({1})', outName(state.dlOut), sel.size);
bGo.classList.toggle('off', !sel.size);
stat.textContent = sel.size ? T('Siap mengunduh {0} gambar · berjalan di latar belakang, boleh pindah halaman', sel.size) : T('Belum ada gambar dipilih');
};
const setAll = (fn) => { for (let i = 0; i < total; i++) { if (fn(i)) sel.add(i); else sel.delete(i); paint(i); } refresh(); };
bAll.addEventListener('click', () => setAll(() => true));
bNone.addEventListener('click', () => setAll(() => false));
bInv.addEventListener('click', () => setAll((i) => !sel.has(i)));
grid.addEventListener('click', (e) => {
const it = e.target.closest('.dl-item');
if (!it) return;
const i = +it.dataset.i;
const on = !sel.has(i);
if (e.shiftKey && last !== null) {
const [a, b] = last < i ? [last, i] : [i, last];
for (let j = a; j <= b; j++) { if (on) sel.add(j); else sel.delete(j); paint(j); }
} else {
if (on) sel.add(i); else sel.delete(i);
paint(i);
}
last = i;
refresh();
});
bCancel.addEventListener('click', closeDl);
bGo.addEventListener('click', async () => {
if (!sel.size) return;
const out = state.dlOut;
const toDir = HAS_DIR && (out === 'img' || state.dlDest === 'dir');
if (toDir) { // harus dipanggil langsung dari klik (user gesture)
try { await ensureDir(); }
catch (e) {
syncOpts();
if (e && e.name === 'AbortError') toast('Pemilihan folder dibatalkan');
else toast(T('Tidak bisa mengakses folder: {0}', (e && e.message) || e), { type: 'err' });
return;
}
syncOpts();
}
const idxs = [...sel].sort((a, b) => a - b);
const job = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
title: rawTitle, subName, gid: String(info.id || ''),
fname: out === 'pdf' ? subName + '.pdf' : zipName,
out, asJpg: out !== 'pdf' && state.dlFmt === 'jpg', q: state.dlQ,
dest: toDir ? 'dir' : 'dl',
pad: Math.max(3, String(total).length),
items: idxs.map((i) => ({ i, urls: candidates(files[i]) })),
failed: [], done: 0, kept: 0, status: 'queued', created: Date.now(),
};
try { await jobPut(job); }
catch (e) { toast(T('Gagal menambahkan ke antrian: {0}', (e && e.message) || e), { type: 'err' }); return; }
closeDl();
toast(T('Ditambahkan ke antrian: {0} gambar. Boleh pindah halaman, unduhan tetap jalan.', idxs.length), { type: 'ok' });
kick();
dmTick();
});
// muat folder tujuan terakhir (kalau pernah dipilih)
if (HAS_DIR && !dirHandle) idbGet('dir').then((h) => { if (h) { dirHandle = h; syncOpts(); } }).catch(() => {});
syncOpts();
refresh();
document.body.appendChild(dlEl);
// posisi & ukuran awal (diingat), lalu aktifkan geser + resize
const r = get('dlRect', null);
const w = Math.min((r && r.w) || 980, innerWidth - 8), h = Math.min((r && r.h) || 760, innerHeight - 8);
box.style.width = w + 'px'; box.style.height = h + 'px';
box.style.left = (r && typeof r.x === 'number' ? r.x : Math.round((innerWidth - w) / 2)) + 'px';
box.style.top = (r && typeof r.y === 'number' ? r.y : Math.round((innerHeight - h) / 2)) + 'px';
keepInView(box);
attachDrag(box, head, 'dlRect');
attachResize(box, 'dlRect', 460, 320);
head.addEventListener('dblclick', (e) => { if (!e.target.closest('button')) box.classList.toggle('min'); });
const onWin = () => keepInView(box);
window.addEventListener('resize', onWin);
dlCleanup = () => window.removeEventListener('resize', onWin);
}
// Intercept klik tombol Download bawaan situs (fase capture, sebelum handler situs).
window.addEventListener('click', (e) => {
if (!state.dlPanel || e.button !== 0) return;
const t = e.target && e.target.closest && e.target.closest('#dl-button');
if (!t) return;
const info = window.galleryinfo;
if (!(info && info.files && info.files.length && window.gg)) return; // data belum siap: pakai download bawaan
e.preventDefault(); e.stopImmediatePropagation();
openDownloadPanel();
}, true);
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dlEl && !modalOpen) { e.stopImmediatePropagation(); closeDl(); }
}, true);
onReady(() => { initPanel(); dmStart(); });
})();