1742 lines
63 KiB
HTML
1742 lines
63 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||
<title>Animex Reader</title>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||
<style>
|
||
:root {
|
||
--background-color: #121212;
|
||
--text-color: #eaeaea;
|
||
--accent-color: #ff9500;
|
||
--accent-hover: #ffaa33;
|
||
--header-bg: rgba(18, 18, 18, 0.88);
|
||
--footer-bg: rgba(18, 18, 18, 0.92);
|
||
--border-color: rgba(255, 255, 255, 0.1);
|
||
--panel-bg: #1c1c1e;
|
||
--zoom: 1;
|
||
}
|
||
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
background-color: var(--background-color);
|
||
color: var(--text-color);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Scrollbar ── */
|
||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||
::-webkit-scrollbar-track { background: #111; }
|
||
::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 3px; }
|
||
::-webkit-scrollbar-thumb:hover { background: #555; }
|
||
|
||
/* ── Header ── */
|
||
#reader-header {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0;
|
||
z-index: 100;
|
||
background-color: var(--header-bg);
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
height: 60px;
|
||
padding: 0 16px;
|
||
gap: 12px;
|
||
transition: transform 0.3s ease-in-out;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
||
}
|
||
#reader-header.hidden { transform: translateY(-100%); }
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Title block */
|
||
.header-titles {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
min-width: 0;
|
||
gap: 0;
|
||
}
|
||
.header-titles h1 {
|
||
font-size: 15px;
|
||
margin: 0;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
line-height: 1.25;
|
||
}
|
||
.header-titles p {
|
||
font-size: 12px;
|
||
margin: 0;
|
||
color: #999;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
/* ── Icon Buttons (back, chapters) ── */
|
||
.icon-circle-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-color);
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
padding: 8px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
transition: background 0.2s;
|
||
}
|
||
.icon-circle-btn:hover { background: rgba(255,255,255,0.1); }
|
||
|
||
/* ── Segmented switcher ── */
|
||
.mode-switcher {
|
||
background: rgba(255,255,255,0.07);
|
||
border-radius: 8px;
|
||
padding: 3px;
|
||
display: flex;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
gap: 2px;
|
||
}
|
||
.mode-switcher button {
|
||
background: transparent;
|
||
border: none;
|
||
color: #999;
|
||
padding: 5px 10px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
transition: all 0.18s;
|
||
white-space: nowrap;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.mode-switcher button:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||
.mode-switcher button.active {
|
||
background: rgba(255,255,255,0.16);
|
||
color: #fff;
|
||
font-weight: 600;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
|
||
}
|
||
|
||
/* Fit switcher — icon-only, smaller */
|
||
#fit-switcher button { padding: 5px 9px; font-size: 13px; }
|
||
|
||
/* ── Direction button ── */
|
||
#direction-btn {
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
color: #aaa;
|
||
padding: 5px 9px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.8px;
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
}
|
||
#direction-btn:hover { color: #fff; background: rgba(255,255,255,0.14); }
|
||
/* Hide direction btn in scroll mode */
|
||
.view-scroll #direction-btn { display: none !important; }
|
||
|
||
/* ── Next Chapter btn ── */
|
||
#next-chapter-btn {
|
||
background: var(--accent-color);
|
||
color: #fff;
|
||
border: none;
|
||
padding: 7px 13px;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
display: none;
|
||
align-items: center;
|
||
gap: 5px;
|
||
box-shadow: 0 2px 8px rgba(255,149,0,0.3);
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
}
|
||
#next-chapter-btn:hover { background: var(--accent-hover); transform: translateY(-1px); }
|
||
|
||
/* ── Central Loader ── */
|
||
#loader {
|
||
position: fixed;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 40px; height: 40px;
|
||
border: 4px solid #2a2a2a;
|
||
border-top-color: var(--accent-color);
|
||
border-radius: 50%;
|
||
animation: spin-center 0.8s linear infinite;
|
||
z-index: 60;
|
||
}
|
||
@keyframes spin-center {
|
||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||
}
|
||
|
||
/* ── Main Image Container ── */
|
||
#image-container {
|
||
height: 100vh;
|
||
width: 100vw;
|
||
background-color: #000;
|
||
position: relative;
|
||
}
|
||
|
||
/* ── SCROLL LAYOUT ── */
|
||
.view-scroll #image-container {
|
||
overflow-y: scroll;
|
||
overflow-x: hidden;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
/* Scroll + fit-both (default) */
|
||
.view-scroll img {
|
||
display: block;
|
||
margin: 0 auto;
|
||
height: auto;
|
||
}
|
||
.view-scroll.fit-both img {
|
||
width: calc(min(900px, 100%) * var(--zoom));
|
||
max-width: none;
|
||
}
|
||
.view-scroll.fit-width img {
|
||
width: calc(100% * var(--zoom));
|
||
max-width: none;
|
||
}
|
||
.view-scroll.fit-height img {
|
||
height: calc(100vh * var(--zoom));
|
||
width: auto;
|
||
max-width: none;
|
||
}
|
||
|
||
/* ── SINGLE LAYOUT ── */
|
||
.view-single #image-container {
|
||
display: flex;
|
||
overflow-x: hidden;
|
||
overflow-y: hidden;
|
||
}
|
||
.view-single img {
|
||
flex-shrink: 0;
|
||
user-select: none;
|
||
-webkit-user-drag: none;
|
||
}
|
||
.view-single.fit-both img {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
object-fit: contain;
|
||
transform: scale(var(--zoom));
|
||
transform-origin: center center;
|
||
}
|
||
.view-single.fit-width img {
|
||
width: 100vw;
|
||
height: auto;
|
||
object-fit: unset;
|
||
transform: scale(var(--zoom));
|
||
transform-origin: top center;
|
||
}
|
||
.view-single.fit-height img {
|
||
height: 100vh;
|
||
width: auto;
|
||
object-fit: unset;
|
||
transform: scale(var(--zoom));
|
||
transform-origin: center center;
|
||
}
|
||
|
||
/* ── DOUBLE LAYOUT ── */
|
||
.view-double #image-container {
|
||
display: flex;
|
||
overflow-x: hidden;
|
||
overflow-y: hidden;
|
||
}
|
||
.view-double .spread {
|
||
display: flex;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
gap: 0;
|
||
}
|
||
.view-double.dir-rtl .spread { flex-direction: row-reverse; }
|
||
.view-double .spread img {
|
||
display: block;
|
||
flex: 0 0 auto;
|
||
min-width: 0;
|
||
user-select: none;
|
||
-webkit-user-drag: none;
|
||
}
|
||
/* fit-both: each page fills its half by height, capped at 50vw wide */
|
||
.view-double.fit-both .spread img {
|
||
width: auto;
|
||
height: calc(100vh * var(--zoom));
|
||
max-width: 50vw;
|
||
object-fit: contain;
|
||
}
|
||
.view-double.fit-width .spread img {
|
||
width: calc(50vw * var(--zoom));
|
||
height: auto;
|
||
max-width: none;
|
||
object-fit: unset;
|
||
}
|
||
.view-double.fit-height .spread img {
|
||
height: calc(100vh * var(--zoom));
|
||
width: auto;
|
||
max-width: 50vw;
|
||
object-fit: unset;
|
||
}
|
||
|
||
/* ── Navigation Overlay (Click Zones) ── */
|
||
.nav-overlay {
|
||
position: fixed;
|
||
top: 0; left: 0;
|
||
height: 100%; width: 100%;
|
||
z-index: 50;
|
||
display: none;
|
||
pointer-events: none;
|
||
}
|
||
.view-single .nav-overlay,
|
||
.view-double .nav-overlay { display: block; }
|
||
#nav-left, #nav-right, #nav-center {
|
||
position: absolute;
|
||
top: 0; height: 100%;
|
||
pointer-events: all;
|
||
}
|
||
#nav-left { left: 0; width: 30%; cursor: w-resize; }
|
||
#nav-right { right: 0; width: 30%; cursor: e-resize; }
|
||
#nav-center { left: 30%; width: 40%; cursor: pointer; }
|
||
|
||
/* ── Footer ── */
|
||
#reader-footer {
|
||
position: fixed;
|
||
bottom: 0; left: 0; right: 0;
|
||
z-index: 100;
|
||
background-color: var(--footer-bg);
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
border-top: 1px solid var(--border-color);
|
||
height: 50px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 14px;
|
||
gap: 10px;
|
||
transition: transform 0.3s ease-in-out;
|
||
}
|
||
#reader-footer.hidden { transform: translateY(100%); }
|
||
|
||
.footer-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.footer-center {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.footer-right {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Footer spinner */
|
||
#footer-loader {
|
||
width: 16px; height: 16px;
|
||
border: 2px solid #333;
|
||
border-top-color: var(--accent-color);
|
||
border-radius: 50%;
|
||
animation: spin-simple 0.8s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
#footer-loader.hidden { display: none; }
|
||
@keyframes spin-simple { to { transform: rotate(360deg); } }
|
||
|
||
/* Zoom controls */
|
||
.zoom-btn {
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
color: var(--text-color);
|
||
width: 26px; height: 26px;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
font-size: 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: 1;
|
||
transition: background 0.2s;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
.zoom-btn:hover { background: rgba(255,255,255,0.15); }
|
||
.zoom-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||
#zoom-label {
|
||
font-size: 11px;
|
||
color: #999;
|
||
min-width: 36px;
|
||
text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 500;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Progress bar */
|
||
#page-progress {
|
||
width: 100%;
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
#page-progress::-webkit-progress-bar {
|
||
background-color: rgba(255,255,255,0.12);
|
||
border-radius: 3px;
|
||
}
|
||
#page-progress::-webkit-progress-value {
|
||
background-color: var(--accent-color);
|
||
border-radius: 3px;
|
||
transition: width 0.1s linear;
|
||
}
|
||
|
||
/* Page indicator */
|
||
#page-indicator {
|
||
font-size: 12px;
|
||
color: #ccc;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 500;
|
||
min-width: 64px;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── Chapter Panel ── */
|
||
#chapter-panel-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
z-index: 200;
|
||
backdrop-filter: blur(3px);
|
||
-webkit-backdrop-filter: blur(3px);
|
||
opacity: 1;
|
||
transition: opacity 0.28s ease;
|
||
}
|
||
#chapter-panel-backdrop.hidden {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
#chapter-panel {
|
||
position: fixed;
|
||
top: 0; right: 0;
|
||
width: min(360px, 92vw);
|
||
height: 100vh;
|
||
background: var(--panel-bg);
|
||
border-left: 1px solid rgba(255,255,255,0.1);
|
||
z-index: 201;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transform: translateX(0);
|
||
transition: transform 0.28s cubic-bezier(0.4,0,0.2,1);
|
||
box-shadow: -8px 0 32px rgba(0,0,0,0.6);
|
||
}
|
||
#chapter-panel.hidden { transform: translateX(100%); }
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 18px;
|
||
height: 60px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||
flex-shrink: 0;
|
||
}
|
||
.panel-header-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
#close-panel-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #888;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
line-height: 1;
|
||
transition: all 0.2s;
|
||
}
|
||
#close-panel-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
||
|
||
#chapter-list {
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
padding: 6px 0;
|
||
}
|
||
.chapter-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 11px 18px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
border-left: 3px solid transparent;
|
||
}
|
||
.chapter-item:hover { background: rgba(255,255,255,0.05); }
|
||
.chapter-item.active {
|
||
background: rgba(255,149,0,0.1);
|
||
border-left-color: var(--accent-color);
|
||
}
|
||
.ch-number {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
color: var(--text-color);
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.ch-title {
|
||
font-size: 12px;
|
||
color: #777;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
}
|
||
.ch-check {
|
||
font-size: 10px;
|
||
flex-shrink: 0;
|
||
opacity: 0;
|
||
color: var(--accent-color);
|
||
transition: opacity 0.2s;
|
||
}
|
||
.chapter-item.finished .ch-number { color: #666; }
|
||
.chapter-item.finished .ch-title { color: #555; }
|
||
.chapter-item.finished .ch-check { opacity: 1; }
|
||
/* Progress badge */
|
||
.ch-badge {
|
||
flex-shrink: 0;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 2px 7px;
|
||
border-radius: 10px;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
.ch-badge.badge-read {
|
||
background: rgba(80,200,120,0.15);
|
||
color: #50c878;
|
||
}
|
||
.ch-badge.badge-reading {
|
||
background: rgba(255,149,0,0.15);
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
/* ── Toast ── */
|
||
#toast {
|
||
position: fixed;
|
||
bottom: 68px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(28,28,30,0.96);
|
||
color: var(--text-color);
|
||
padding: 9px 18px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
z-index: 400;
|
||
pointer-events: none;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
box-shadow: 0 6px 20px rgba(0,0,0,0.5);
|
||
opacity: 1;
|
||
transition: opacity 0.3s ease;
|
||
white-space: nowrap;
|
||
}
|
||
#toast.hidden { opacity: 0; }
|
||
|
||
/* ── Responsive ── */
|
||
@media (min-width: 768px) {
|
||
.header-titles h1 { font-size: 16px; }
|
||
.header-titles p { font-size: 13px; }
|
||
#page-progress { height: 8px; }
|
||
}
|
||
@media (max-width: 600px) {
|
||
.mode-switcher button { padding: 5px 7px; font-size: 11px; gap: 3px; }
|
||
#next-chapter-btn { padding: 6px 10px; font-size: 11px; }
|
||
#direction-btn { padding: 5px 7px; font-size: 10px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="view-single fit-both dir-ltr">
|
||
|
||
<!-- ══ HEADER ══ -->
|
||
<header id="reader-header">
|
||
<div class="header-left">
|
||
<button id="back-btn" class="icon-circle-btn" aria-label="Go back">
|
||
<i class="fa fa-arrow-left"></i>
|
||
</button>
|
||
<button id="chapters-btn" class="icon-circle-btn" aria-label="Chapter list" title="Chapter List">
|
||
<i class="fa fa-list"></i>
|
||
</button>
|
||
<div class="header-titles">
|
||
<h1 id="manga-title">Loading Series…</h1>
|
||
<p id="chapter-details">Loading Chapter…</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="header-right">
|
||
<!-- Layout: Scroll | Single | Double -->
|
||
<div class="mode-switcher" id="layout-switcher">
|
||
<button id="layout-scroll" title="Scroll (Webtoon)"><i class="fa fa-scroll"></i> Scroll</button>
|
||
<button id="layout-single" class="active" title="Single Page"><i class="fa fa-book-open"></i> Single</button>
|
||
<button id="layout-double" title="Double Page"><i class="fa fa-book"></i> Double</button>
|
||
</div>
|
||
|
||
<!-- Fit: Width | Height | Both -->
|
||
<div class="mode-switcher" id="fit-switcher">
|
||
<button id="fit-width" title="Fit Width"><i class="fa fa-arrows-left-right"></i></button>
|
||
<button id="fit-height" title="Fit Height"><i class="fa fa-arrows-up-down"></i></button>
|
||
<button id="fit-both" class="active" title="Fit Both (Contain)"><i class="fa fa-compress"></i></button>
|
||
</div>
|
||
|
||
<!-- Direction (hidden in scroll mode) -->
|
||
<button id="direction-btn" title="Toggle Reading Direction">LTR</button>
|
||
|
||
<!-- Next Chapter -->
|
||
<button id="next-chapter-btn">
|
||
Next <i class="fa fa-chevron-right"></i>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ══ CENTERED LOADER ══ -->
|
||
<div id="loader"></div>
|
||
|
||
<!-- ══ IMAGE CONTAINER ══ -->
|
||
<main id="image-container"></main>
|
||
|
||
<!-- ══ NAV OVERLAY (paged modes) ══ -->
|
||
<div class="nav-overlay">
|
||
<div id="nav-left" title="Previous Page"></div>
|
||
<div id="nav-center" title="Toggle UI"></div>
|
||
<div id="nav-right" title="Next Page"></div>
|
||
</div>
|
||
|
||
<!-- ══ FOOTER ══ -->
|
||
<footer id="reader-footer">
|
||
<div class="footer-left">
|
||
<div id="footer-loader" class="hidden"></div>
|
||
<button id="zoom-out" class="zoom-btn" title="Zoom Out">−</button>
|
||
<span id="zoom-label">100%</span>
|
||
<button id="zoom-in" class="zoom-btn" title="Zoom In">+</button>
|
||
</div>
|
||
<div class="footer-center">
|
||
<progress id="page-progress" value="0" max="100"></progress>
|
||
</div>
|
||
<div class="footer-right">
|
||
<span id="page-indicator">0 / 0</span>
|
||
</div>
|
||
</footer>
|
||
|
||
<!-- ══ CHAPTER PANEL ══ -->
|
||
<div id="chapter-panel-backdrop" class="hidden"></div>
|
||
<div id="chapter-panel" class="hidden">
|
||
<div class="panel-header">
|
||
<span class="panel-header-title">Chapters</span>
|
||
<button id="close-panel-btn" aria-label="Close chapter list">✕</button>
|
||
</div>
|
||
<div id="chapter-list"></div>
|
||
</div>
|
||
|
||
<!-- ══ TOAST ══ -->
|
||
<div id="toast" class="hidden"></div>
|
||
|
||
<script>
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// ELEMENTS
|
||
// ═══════════════════════════════════════════════════════════
|
||
const container = document.getElementById("image-container");
|
||
const loader = document.getElementById("loader");
|
||
const footerLoader = document.getElementById("footer-loader");
|
||
const header = document.getElementById("reader-header");
|
||
const footer = document.getElementById("reader-footer");
|
||
const mangaTitleEl = document.getElementById("manga-title");
|
||
const chapterDetailsEl= document.getElementById("chapter-details");
|
||
const backBtn = document.getElementById("back-btn");
|
||
const chaptersBtn = document.getElementById("chapters-btn");
|
||
const nextChapterBtn = document.getElementById("next-chapter-btn");
|
||
const pageProgress = document.getElementById("page-progress");
|
||
const pageIndicator = document.getElementById("page-indicator");
|
||
const zoomOutBtn = document.getElementById("zoom-out");
|
||
const zoomInBtn = document.getElementById("zoom-in");
|
||
const zoomLabel = document.getElementById("zoom-label");
|
||
const directionBtn = document.getElementById("direction-btn");
|
||
const chapterPanel = document.getElementById("chapter-panel");
|
||
const panelBackdrop = document.getElementById("chapter-panel-backdrop");
|
||
const chapterListEl = document.getElementById("chapter-list");
|
||
const closePanelBtn = document.getElementById("close-panel-btn");
|
||
const toast = document.getElementById("toast");
|
||
const navLeft = document.getElementById("nav-left");
|
||
const navRight = document.getElementById("nav-right");
|
||
const navCenter = document.getElementById("nav-center");
|
||
const layoutScrollBtn = document.getElementById("layout-scroll");
|
||
const layoutSingleBtn = document.getElementById("layout-single");
|
||
const layoutDoubleBtn = document.getElementById("layout-double");
|
||
const fitWidthBtn = document.getElementById("fit-width");
|
||
const fitHeightBtn = document.getElementById("fit-height");
|
||
const fitBothBtn = document.getElementById("fit-both");
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// CONSTANTS / KEYS
|
||
// ═══════════════════════════════════════════════════════════
|
||
const serverUrl = ``;
|
||
const LAYOUT_KEY = "reader_layout_mode"; // "scroll"|"single"|"double"
|
||
const FIT_KEY = "reader_fit_mode"; // "fit-width"|"fit-height"|"fit-both"
|
||
const DIRECTION_KEY = "reader_direction"; // "ltr"|"rtl"
|
||
const ZOOM_KEY = "reader_zoom_level"; // number 0.5–3.0
|
||
const HISTORY_KEY = "reading_history";
|
||
|
||
const ZOOM_MIN = 0.5;
|
||
const ZOOM_MAX = 3.0;
|
||
const ZOOM_STEP = 0.1;
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STATE
|
||
// ═══════════════════════════════════════════════════════════
|
||
let imageLinks = [];
|
||
let currentPage = 0; // always an image index (0-based)
|
||
let source, mangaId, chapterId;
|
||
let chapterData = null;
|
||
let allChapters = []; // [{id, number, title}, ...]
|
||
let currentChapterIndex= -1;
|
||
let layoutMode = "single";
|
||
let fitMode = "fit-both";
|
||
let direction = "ltr";
|
||
let zoomLevel = 1.0;
|
||
let toastTimeout = null;
|
||
let panelOpen = false;
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════════════════════
|
||
function init() {
|
||
const pathParts = window.location.pathname.split("/");
|
||
// Expected: /read/{source}/{mangaId}/{chapterId}
|
||
if (pathParts.length >= 5) {
|
||
source = pathParts[2];
|
||
mangaId = pathParts[3];
|
||
chapterId = pathParts[4];
|
||
} else {
|
||
console.error("Invalid URL structure");
|
||
return;
|
||
}
|
||
|
||
// Load preferences
|
||
layoutMode = localStorage.getItem(LAYOUT_KEY) || "single";
|
||
fitMode = localStorage.getItem(FIT_KEY) || "fit-both";
|
||
direction = localStorage.getItem(DIRECTION_KEY) || "ltr";
|
||
zoomLevel = parseFloat(localStorage.getItem(ZOOM_KEY)) || 1.0;
|
||
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoomLevel));
|
||
|
||
// Apply body classes immediately
|
||
document.body.className = `view-${layoutMode} ${fitMode} dir-${direction}`;
|
||
applyZoom(zoomLevel, false);
|
||
updateLayoutButtons();
|
||
updateFitButtons();
|
||
updateDirectionButton();
|
||
|
||
// Fetch
|
||
fetchMangaTitle();
|
||
fetchAllChapters().then(() => fetchChapterDetails());
|
||
fetchImages();
|
||
setupControls();
|
||
|
||
// popstate (browser back/forward within reader)
|
||
window.addEventListener("popstate", (e) => {
|
||
if (e.state && e.state.chapterId && e.state.chapterId !== chapterId) {
|
||
loadChapter(e.state.chapterId, true /* isPop */);
|
||
}
|
||
});
|
||
|
||
// Replace current history entry with state so popstate works
|
||
history.replaceState({ source, mangaId, chapterId }, "", window.location.href);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// HISTORY MANAGEMENT
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* History structure in localStorage:
|
||
* [
|
||
* {
|
||
* mangaId: "123",
|
||
* source: "mangadex",
|
||
* chapters: {
|
||
* "chapter-uuid": {
|
||
* state: "ongoing" | "finished",
|
||
* page: 4,
|
||
* timestamp: 123456789,
|
||
* source: "mangadex"
|
||
* }
|
||
* }
|
||
* }
|
||
* ]
|
||
*/
|
||
|
||
function getHistory() {
|
||
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; }
|
||
catch (e) { return []; }
|
||
}
|
||
|
||
function saveHistory(history) {
|
||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||
}
|
||
|
||
function updateHistory() {
|
||
if (imageLinks.length === 0) return;
|
||
let hist = getHistory();
|
||
|
||
let seriesEntry = hist.find(h => h.mangaId === mangaId && h.source === source);
|
||
if (!seriesEntry) {
|
||
seriesEntry = { mangaId, source, chapters: {} };
|
||
hist.push(seriesEntry);
|
||
}
|
||
|
||
const isFinished = currentPage >= imageLinks.length - 1;
|
||
seriesEntry.chapters[chapterId] = {
|
||
state: isFinished ? "finished" : "ongoing",
|
||
page: currentPage,
|
||
timestamp: Date.now(),
|
||
source
|
||
};
|
||
|
||
saveHistory(hist);
|
||
}
|
||
|
||
function restoreReadingProgress() {
|
||
const hist = getHistory();
|
||
const seriesEntry = hist.find(h => h.mangaId === mangaId && h.source === source);
|
||
|
||
if (seriesEntry?.chapters?.[chapterId]) {
|
||
const record = seriesEntry.chapters[chapterId];
|
||
|
||
if (record.state === "finished") {
|
||
// Chapter already finished → start fresh from page 0
|
||
currentPage = 0;
|
||
} else if (record.page > 0 && record.page < imageLinks.length) {
|
||
currentPage = record.page;
|
||
// In double mode, snap currentPage to even (start of spread)
|
||
if (layoutMode === "double" && currentPage % 2 !== 0) {
|
||
currentPage = Math.max(0, currentPage - 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Apply position
|
||
if (layoutMode !== "scroll") {
|
||
updatePagination();
|
||
} else {
|
||
setTimeout(() => {
|
||
const imgs = getAllImages();
|
||
if (imgs[currentPage]) {
|
||
imgs[currentPage].scrollIntoView({ behavior: "auto", block: "start" });
|
||
}
|
||
}, 100);
|
||
}
|
||
updateFooter();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// CHAPTER MANAGEMENT
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
async function fetchAllChapters() {
|
||
const url = source === "mangadex"
|
||
? `${serverUrl}/mangadex/manga/${mangaId}/chapters`
|
||
: `${serverUrl}/chapters/${mangaId}`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
// Support both flat `data.chapters` and `data.modules` (keyed by source)
|
||
let rawChapters = [];
|
||
if (Array.isArray(data.chapters)) {
|
||
rawChapters = data.chapters;
|
||
} else if (data.modules && typeof data.modules === "object") {
|
||
const preferred = data.modules[source];
|
||
if (Array.isArray(preferred) && preferred.length > 0) {
|
||
rawChapters = preferred;
|
||
} else {
|
||
const firstKey = Object.keys(data.modules)[0];
|
||
rawChapters = Array.isArray(data.modules[firstKey]) ? data.modules[firstKey] : [];
|
||
}
|
||
}
|
||
|
||
allChapters = rawChapters.map(c => {
|
||
if (source === "mangadex") {
|
||
return {
|
||
id: c.id,
|
||
number: c.attributes?.chapter ?? "?",
|
||
title: c.attributes?.title || ""
|
||
};
|
||
} else {
|
||
return {
|
||
id: c.id ?? c.chapter_number,
|
||
number: c.chapter_number ?? c.id,
|
||
title: c.title || ""
|
||
};
|
||
}
|
||
});
|
||
|
||
allChapters.reverse();
|
||
|
||
currentChapterIndex = allChapters.findIndex(c => String(c.id) === String(chapterId));
|
||
renderChapterPanel();
|
||
} catch (e) {
|
||
console.error("fetchAllChapters failed:", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* In-page chapter switch — no redirect.
|
||
* isPop = true when called from popstate (skip pushState).
|
||
*/
|
||
async function loadChapter(newChapterId, isPop = false) {
|
||
if (String(newChapterId) === String(chapterId)) return;
|
||
|
||
// Save progress for the chapter we're leaving
|
||
updateHistory();
|
||
|
||
showToast("Loading chapter…");
|
||
|
||
// Reset state
|
||
imageLinks = [];
|
||
currentPage = 0;
|
||
chapterId = String(newChapterId);
|
||
currentChapterIndex = allChapters.findIndex(c => String(c.id) === String(newChapterId));
|
||
chapterData = null;
|
||
|
||
// Reset UI
|
||
container.innerHTML = "";
|
||
pageIndicator.textContent = "0 / 0";
|
||
pageProgress.value = 0;
|
||
pageProgress.max = 100;
|
||
nextChapterBtn.style.display = "none";
|
||
footerLoader.classList.add("hidden");
|
||
|
||
// Show centered loader again
|
||
loader.style.display = "";
|
||
|
||
// Update URL (unless triggered by browser nav)
|
||
if (!isPop) {
|
||
history.pushState(
|
||
{ source, mangaId, chapterId },
|
||
"",
|
||
`/read/${source}/${mangaId}/${newChapterId}`
|
||
);
|
||
}
|
||
|
||
// Re-highlight panel (if open)
|
||
updatePanelHighlight();
|
||
|
||
// Fetch new chapter data (non-blocking title re-fetch not needed — stays same manga)
|
||
fetchChapterDetails();
|
||
await fetchImages();
|
||
}
|
||
|
||
function openPanel() {
|
||
renderChapterPanel();
|
||
chapterPanel.classList.remove("hidden");
|
||
panelBackdrop.classList.remove("hidden");
|
||
panelOpen = true;
|
||
}
|
||
|
||
function closePanel() {
|
||
chapterPanel.classList.add("hidden");
|
||
panelBackdrop.classList.add("hidden");
|
||
panelOpen = false;
|
||
}
|
||
|
||
function renderChapterPanel() {
|
||
chapterListEl.innerHTML = "";
|
||
if (allChapters.length === 0) {
|
||
const empty = document.createElement("div");
|
||
empty.style.cssText = "padding:24px 18px;color:#666;font-size:13px;";
|
||
empty.textContent = "No chapters available.";
|
||
chapterListEl.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const hist = getHistory();
|
||
const seriesEntry = hist.find(h => h.mangaId === mangaId && h.source === source);
|
||
|
||
allChapters.forEach(ch => {
|
||
const item = document.createElement("div");
|
||
item.className = "chapter-item";
|
||
item.dataset.chId = String(ch.id);
|
||
|
||
if (String(ch.id) === String(chapterId)) item.classList.add("active");
|
||
|
||
// Look up history by string AND number key for safety
|
||
const chRecord = seriesEntry?.chapters?.[ch.id]
|
||
?? seriesEntry?.chapters?.[String(ch.id)];
|
||
|
||
if (chRecord?.state === "finished") item.classList.add("finished");
|
||
|
||
const numSpan = document.createElement("span");
|
||
numSpan.className = "ch-number";
|
||
numSpan.textContent = `Ch. ${ch.number}`;
|
||
|
||
const titleSpan = document.createElement("span");
|
||
titleSpan.className = "ch-title";
|
||
titleSpan.textContent = ch.title || "";
|
||
|
||
const check = document.createElement("i");
|
||
check.className = "fa fa-check ch-check";
|
||
|
||
item.appendChild(numSpan);
|
||
item.appendChild(titleSpan);
|
||
|
||
// ── Progress badge ──
|
||
if (chRecord) {
|
||
const badge = document.createElement("span");
|
||
badge.className = "ch-badge";
|
||
if (chRecord.state === "finished") {
|
||
badge.classList.add("badge-read");
|
||
badge.textContent = "Read";
|
||
} else if (chRecord.state === "ongoing") {
|
||
badge.classList.add("badge-reading");
|
||
// Show page progress if we know the total (only available for current chapter)
|
||
const total = (String(ch.id) === String(chapterId)) ? imageLinks.length : 0;
|
||
if (total > 0) {
|
||
badge.textContent = `Pg ${chRecord.page + 1}/${total}`;
|
||
} else {
|
||
badge.textContent = chRecord.page > 0 ? `Pg ${chRecord.page + 1}` : "Reading";
|
||
}
|
||
}
|
||
item.appendChild(badge);
|
||
}
|
||
|
||
item.appendChild(check);
|
||
|
||
item.addEventListener("click", () => {
|
||
if (String(ch.id) !== String(chapterId)) {
|
||
loadChapter(ch.id);
|
||
}
|
||
});
|
||
|
||
chapterListEl.appendChild(item);
|
||
});
|
||
|
||
// Scroll active item into view
|
||
requestAnimationFrame(() => {
|
||
const active = chapterListEl.querySelector(".chapter-item.active");
|
||
if (active) active.scrollIntoView({ block: "nearest" });
|
||
});
|
||
}
|
||
|
||
function updatePanelHighlight() {
|
||
const items = chapterListEl.querySelectorAll(".chapter-item");
|
||
items.forEach(item => {
|
||
item.classList.toggle("active", item.dataset.chId === String(chapterId));
|
||
});
|
||
const active = chapterListEl.querySelector(".chapter-item.active");
|
||
if (active) active.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// FETCHING LOGIC
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
async function fetchMangaTitle() {
|
||
const url = source === "mangadex"
|
||
? `${serverUrl}/mangadex/manga/${mangaId}`
|
||
: `https://api.jikan.moe/v4/manga/${mangaId}`;
|
||
try {
|
||
const res = await fetch(url);
|
||
const data = await res.json();
|
||
if (source === "mangadex") {
|
||
mangaTitleEl.textContent =
|
||
data.attributes?.title?.en ||
|
||
Object.values(data.attributes?.title || {})[0] ||
|
||
"Unknown Title";
|
||
} else {
|
||
mangaTitleEl.textContent = data.data?.title || "Unknown Title";
|
||
}
|
||
} catch (e) {
|
||
mangaTitleEl.textContent = "Reader";
|
||
}
|
||
}
|
||
|
||
async function fetchChapterDetails() {
|
||
// ── Legacy (non-mangadex) ──
|
||
if (source !== "mangadex") {
|
||
const ch = allChapters[currentChapterIndex];
|
||
if (ch) {
|
||
const title = ch.title ? ` · ${ch.title}` : "";
|
||
chapterDetailsEl.textContent = `Ch. ${ch.number}${title}`;
|
||
} else {
|
||
chapterDetailsEl.textContent = `Ch. ${chapterId}`;
|
||
}
|
||
// Wire next chapter button via allChapters
|
||
const nextCh = allChapters[currentChapterIndex + 1];
|
||
if (nextCh) {
|
||
nextChapterBtn.style.display = "flex";
|
||
nextChapterBtn.onclick = () => loadChapter(nextCh.id);
|
||
} else {
|
||
nextChapterBtn.style.display = "none";
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── MangaDex ──
|
||
try {
|
||
const url = `${serverUrl}/mangadex/manga/${mangaId}/chapter-nav-details/${chapterId}`;
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error("Nav fetch failed");
|
||
const navData = await response.json();
|
||
chapterData = navData.current_chapter;
|
||
|
||
if (chapterData?.attributes) {
|
||
const chNum = chapterData.attributes.chapter;
|
||
const title = chapterData.attributes.title;
|
||
chapterDetailsEl.textContent =
|
||
`Ch. ${chNum}${title ? " · " + title : ""}`;
|
||
} else {
|
||
chapterDetailsEl.textContent = `Ch. ${chapterId}`;
|
||
}
|
||
|
||
if (navData.next_chapter_id) {
|
||
nextChapterBtn.style.display = "flex";
|
||
nextChapterBtn.onclick = () => loadChapter(navData.next_chapter_id);
|
||
} else {
|
||
nextChapterBtn.style.display = "none";
|
||
}
|
||
} catch (error) {
|
||
console.warn("Nav details error:", error);
|
||
chapterDetailsEl.textContent = `Ch. ${chapterId}`;
|
||
// Fallback: use allChapters for next btn
|
||
const nextCh = allChapters[currentChapterIndex + 1];
|
||
if (nextCh) {
|
||
nextChapterBtn.style.display = "flex";
|
||
nextChapterBtn.onclick = () => loadChapter(nextCh.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchImages() {
|
||
const url = source === "mangadex"
|
||
? `${serverUrl}/mangadex/chapter/${chapterId}`
|
||
: `${serverUrl}/retrieve/${mangaId}/${chapterId}`;
|
||
try {
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error("Image fetch failed");
|
||
imageLinks = await response.json();
|
||
if (!imageLinks || imageLinks.length === 0) throw new Error("No images found");
|
||
await preloadInitialImages();
|
||
} catch (error) {
|
||
console.error(error);
|
||
loader.style.borderTopColor = "red";
|
||
chapterDetailsEl.textContent = "Error loading images.";
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// PROXY DETECTION — probe once, then route all images
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
// null = unknown (probe pending), true = direct works, false = must proxy
|
||
let _directAccessOk = null;
|
||
// Queue of callbacks waiting on the probe result
|
||
let _probeCallbacks = [];
|
||
|
||
/**
|
||
* Run (or await) the one-time connectivity probe.
|
||
* Resolves with true (direct) or false (proxy fallback).
|
||
*/
|
||
async function probeDirectAccess(sampleUrl) {
|
||
if (_directAccessOk !== null) return _directAccessOk;
|
||
|
||
return new Promise(resolve => {
|
||
_probeCallbacks.push(resolve);
|
||
|
||
// Only the first caller kicks off the real probe
|
||
if (_probeCallbacks.length > 1) return;
|
||
|
||
const probe = new Image();
|
||
const timer = setTimeout(() => settle(false), 8000); // 8 s timeout
|
||
|
||
function settle(ok) {
|
||
clearTimeout(timer);
|
||
_directAccessOk = ok;
|
||
console.log(`[reader] direct-access probe: ${ok ? "✓ direct" : "✗ using proxy"}`);
|
||
_probeCallbacks.forEach(cb => cb(ok));
|
||
_probeCallbacks = [];
|
||
}
|
||
|
||
probe.onload = () => settle(true);
|
||
probe.onerror = () => settle(false);
|
||
// Cache-bust so the browser actually fires a network request
|
||
probe.src = sampleUrl + (sampleUrl.includes("?") ? "&" : "?") + "_probe=" + Date.now();
|
||
});
|
||
}
|
||
|
||
function proxyUrl(src) {
|
||
if (!src || src.startsWith("/")) return src;
|
||
return `${serverUrl}/proxy-image?url=${encodeURIComponent(src)}`;
|
||
}
|
||
|
||
/**
|
||
* Return the URL to use for an image.
|
||
* Uses cached probe result — call after probeDirectAccess() has settled.
|
||
*/
|
||
function resolvedUrl(src) {
|
||
if (!src || src.startsWith("/")) return src;
|
||
return _directAccessOk ? src : proxyUrl(src);
|
||
}
|
||
|
||
/**
|
||
* Attach src to an img element with automatic proxy retry on error.
|
||
* @param {HTMLImageElement} img
|
||
* @param {string} rawSrc — the original (un-proxied) URL
|
||
*/
|
||
function setImgSrc(img, rawSrc) {
|
||
if (!rawSrc) return;
|
||
const direct = _directAccessOk !== false; // use direct if probe passed or still unknown
|
||
img.src = direct && !rawSrc.startsWith("/") ? rawSrc : proxyUrl(rawSrc);
|
||
img.dataset.rawSrc = rawSrc; // store original for retry
|
||
|
||
img.onerror = function retryWithProxy() {
|
||
img.onerror = null; // prevent infinite loop
|
||
const proxied = proxyUrl(rawSrc);
|
||
if (img.src !== proxied) {
|
||
console.warn("[reader] direct load failed, retrying via proxy:", rawSrc);
|
||
_directAccessOk = false; // cache result for future images
|
||
img.src = proxied;
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Build the DOM skeleton for images (flat or spread-based). */
|
||
function buildImageDOM() {
|
||
container.innerHTML = "";
|
||
if (layoutMode === "double") {
|
||
for (let i = 0; i < imageLinks.length; i += 2) {
|
||
const spread = document.createElement("div");
|
||
spread.className = "spread";
|
||
const img1 = document.createElement("img");
|
||
img1.loading = "lazy";
|
||
spread.appendChild(img1);
|
||
if (i + 1 < imageLinks.length) {
|
||
const img2 = document.createElement("img");
|
||
img2.loading = "lazy";
|
||
spread.appendChild(img2);
|
||
}
|
||
container.appendChild(spread);
|
||
}
|
||
} else {
|
||
imageLinks.forEach(() => {
|
||
const img = document.createElement("img");
|
||
img.loading = "lazy";
|
||
container.appendChild(img);
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Returns ALL img elements regardless of spread wrapper depth. */
|
||
function getAllImages() {
|
||
return Array.from(container.querySelectorAll("img"));
|
||
}
|
||
|
||
async function preloadInitialImages() {
|
||
buildImageDOM();
|
||
|
||
const allImgs = getAllImages();
|
||
const toPreload = allImgs.slice(0, 4);
|
||
|
||
// ── ONE-TIME CONNECTIVITY PROBE ──
|
||
// Try the first raw image URL directly; fall back to proxy for everything if it fails.
|
||
if (imageLinks[0]) {
|
||
await probeDirectAccess(imageLinks[0]);
|
||
}
|
||
|
||
// ── Auto-guesser: detect tall (webtoon-style) images ──
|
||
if (toPreload[0] && imageLinks[0]) {
|
||
const tempImg = new Image();
|
||
tempImg.src = resolvedUrl(imageLinks[0]);
|
||
await new Promise(r => {
|
||
tempImg.onload = () => {
|
||
const isTall = tempImg.height > tempImg.width * 2;
|
||
const userSetLayout = localStorage.getItem(LAYOUT_KEY);
|
||
// Guesser only overrides layout to scroll (never changes single↔double)
|
||
// And is suppressed entirely if user has set a preference
|
||
if (isTall && !userSetLayout) {
|
||
setLayout("scroll", true /* isAutoGuess */);
|
||
// Rebuild DOM since layout changed
|
||
buildImageDOM();
|
||
}
|
||
r();
|
||
};
|
||
tempImg.onerror = r;
|
||
});
|
||
}
|
||
|
||
// Re-query after potential DOM rebuild
|
||
const imgs = getAllImages();
|
||
const firstFour = imgs.slice(0, 4);
|
||
|
||
// Preload first 4 images (probe already settled, so setImgSrc picks the right path)
|
||
const promises = firstFour.map((img, idx) =>
|
||
new Promise(resolve => {
|
||
img.onload = resolve;
|
||
// onerror handled inside setImgSrc (proxy retry); resolve after that too
|
||
const origOnerror = null;
|
||
img.addEventListener("load", resolve, { once: true });
|
||
img.addEventListener("error", resolve, { once: true });
|
||
setImgSrc(img, imageLinks[idx]);
|
||
})
|
||
);
|
||
|
||
await Promise.all(promises);
|
||
|
||
// ── First images ready: move loader to footer ──
|
||
loader.style.display = "none";
|
||
if (imageLinks.length > 4) {
|
||
footerLoader.classList.remove("hidden");
|
||
}
|
||
|
||
// Restore reading position
|
||
restoreReadingProgress();
|
||
|
||
// Load the rest in the background
|
||
loadRemainingImages();
|
||
}
|
||
|
||
function loadRemainingImages() {
|
||
const imgs = getAllImages();
|
||
const remaining = imgs.slice(4);
|
||
|
||
if (remaining.length === 0) {
|
||
footerLoader.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
let loadedCount = 0;
|
||
remaining.forEach((img, idx) => {
|
||
const done = () => {
|
||
loadedCount++;
|
||
if (loadedCount >= remaining.length) {
|
||
footerLoader.classList.add("hidden");
|
||
}
|
||
};
|
||
// Use resolved URL with automatic proxy-retry on individual failures
|
||
img.addEventListener("load", done, { once: true });
|
||
img.addEventListener("error", done, { once: true });
|
||
setImgSrc(img, imageLinks[idx + 4]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Rebuild the image container DOM when switching between
|
||
* double ↔ single/scroll (where spread wrappers are needed).
|
||
* Reuses already-loaded image srcs to avoid re-fetching.
|
||
*/
|
||
function rebuildImageContainer() {
|
||
if (imageLinks.length === 0) return;
|
||
|
||
// Preserve original (raw) srcs so we don't double-proxy
|
||
const existingRawSrcs = getAllImages().map(img => img.dataset.rawSrc || img.src || "");
|
||
buildImageDOM();
|
||
|
||
const newImgs = getAllImages();
|
||
newImgs.forEach((img, i) => {
|
||
if (existingRawSrcs[i]) setImgSrc(img, existingRawSrcs[i]);
|
||
});
|
||
|
||
// Restore scroll position after rebuild
|
||
if (layoutMode === "scroll") {
|
||
setTimeout(() => {
|
||
const imgs = getAllImages();
|
||
if (imgs[currentPage]) {
|
||
imgs[currentPage].scrollIntoView({ behavior: "auto", block: "start" });
|
||
}
|
||
}, 50);
|
||
} else {
|
||
// Snap currentPage to even in double mode
|
||
if (layoutMode === "double" && currentPage % 2 !== 0) {
|
||
currentPage = Math.max(0, currentPage - 1);
|
||
}
|
||
updatePagination();
|
||
}
|
||
updateFooter();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// LAYOUT & APPEARANCE
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
function setLayout(mode, isAutoGuess = false) {
|
||
const prevMode = layoutMode;
|
||
const needRebuild = (mode === "double" || prevMode === "double") && mode !== prevMode;
|
||
layoutMode = mode;
|
||
|
||
document.body.classList.remove("view-scroll", "view-single", "view-double");
|
||
document.body.classList.add(`view-${mode}`);
|
||
|
||
updateLayoutButtons();
|
||
updateDirectionButton(); // show/hide based on scroll vs paged
|
||
|
||
if (!isAutoGuess) {
|
||
localStorage.setItem(LAYOUT_KEY, mode);
|
||
}
|
||
|
||
if (imageLinks.length > 0) {
|
||
if (needRebuild) {
|
||
rebuildImageContainer();
|
||
} else if (mode === "scroll") {
|
||
const imgs = getAllImages();
|
||
if (imgs[currentPage]) {
|
||
imgs[currentPage].scrollIntoView({ behavior: "auto", block: "start" });
|
||
}
|
||
} else {
|
||
if (mode === "double" && currentPage % 2 !== 0) {
|
||
currentPage = Math.max(0, currentPage - 1);
|
||
}
|
||
updatePagination();
|
||
}
|
||
}
|
||
updateFooter();
|
||
}
|
||
|
||
function setFit(mode) {
|
||
document.body.classList.remove("fit-width", "fit-height", "fit-both");
|
||
document.body.classList.add(mode);
|
||
fitMode = mode;
|
||
localStorage.setItem(FIT_KEY, mode);
|
||
updateFitButtons();
|
||
}
|
||
|
||
function setDirection(dir) {
|
||
document.body.classList.remove("dir-ltr", "dir-rtl");
|
||
document.body.classList.add(`dir-${dir}`);
|
||
direction = dir;
|
||
localStorage.setItem(DIRECTION_KEY, dir);
|
||
updateDirectionButton();
|
||
}
|
||
|
||
function applyZoom(level, save = true) {
|
||
zoomLevel = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
|
||
document.documentElement.style.setProperty("--zoom", zoomLevel);
|
||
zoomLabel.textContent = `${Math.round(zoomLevel * 100)}%`;
|
||
zoomOutBtn.disabled = zoomLevel <= ZOOM_MIN;
|
||
zoomInBtn.disabled = zoomLevel >= ZOOM_MAX;
|
||
if (save) localStorage.setItem(ZOOM_KEY, zoomLevel);
|
||
}
|
||
|
||
function updateLayoutButtons() {
|
||
layoutScrollBtn.classList.toggle("active", layoutMode === "scroll");
|
||
layoutSingleBtn.classList.toggle("active", layoutMode === "single");
|
||
layoutDoubleBtn.classList.toggle("active", layoutMode === "double");
|
||
}
|
||
|
||
function updateFitButtons() {
|
||
fitWidthBtn.classList.toggle("active", fitMode === "fit-width");
|
||
fitHeightBtn.classList.toggle("active", fitMode === "fit-height");
|
||
fitBothBtn.classList.toggle("active", fitMode === "fit-both");
|
||
}
|
||
|
||
function updateDirectionButton() {
|
||
if (layoutMode === "scroll") {
|
||
directionBtn.style.display = "none";
|
||
} else {
|
||
directionBtn.style.display = "";
|
||
directionBtn.textContent = direction.toUpperCase();
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// NAVIGATION
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
function updatePagination() {
|
||
if (layoutMode === "double") {
|
||
const spreadIndex = Math.floor(currentPage / 2);
|
||
container.scrollLeft = spreadIndex * window.innerWidth;
|
||
} else {
|
||
container.scrollLeft = currentPage * window.innerWidth;
|
||
}
|
||
updateHistory();
|
||
}
|
||
|
||
function updateFooter() {
|
||
if (imageLinks.length === 0) {
|
||
pageIndicator.textContent = "0 / 0";
|
||
pageProgress.value = 0;
|
||
pageProgress.max = 100;
|
||
return;
|
||
}
|
||
|
||
if (layoutMode === "scroll") {
|
||
// Handled by scroll listener (updateScrollProgress)
|
||
} else if (layoutMode === "double") {
|
||
const pg2 = Math.min(currentPage + 2, imageLinks.length);
|
||
const display = currentPage + 1 === pg2
|
||
? `${pg2} / ${imageLinks.length}`
|
||
: `${currentPage + 1}–${pg2} / ${imageLinks.length}`;
|
||
pageIndicator.textContent = display;
|
||
const spreadIndex = Math.floor(currentPage / 2);
|
||
const totalSpreads = Math.ceil(imageLinks.length / 2);
|
||
pageProgress.value = spreadIndex + 1;
|
||
pageProgress.max = totalSpreads;
|
||
} else {
|
||
pageIndicator.textContent = `${currentPage + 1} / ${imageLinks.length}`;
|
||
pageProgress.value = currentPage + 1;
|
||
pageProgress.max = imageLinks.length;
|
||
}
|
||
}
|
||
|
||
function updateScrollProgress() {
|
||
if (imageLinks.length === 0) return;
|
||
|
||
const scrollableH = container.scrollHeight - container.clientHeight;
|
||
const pct = scrollableH > 0 ? (container.scrollTop / scrollableH) * 100 : 0;
|
||
pageProgress.value = pct;
|
||
pageProgress.max = 100;
|
||
|
||
// Detect current page by visibility
|
||
const imgs = getAllImages();
|
||
let visible = currentPage;
|
||
for (let i = 0; i < imgs.length; i++) {
|
||
const rect = imgs[i].getBoundingClientRect();
|
||
if (rect.bottom > 0 && rect.top < window.innerHeight / 2) {
|
||
visible = i;
|
||
}
|
||
}
|
||
if (visible !== currentPage) {
|
||
currentPage = visible;
|
||
pageIndicator.textContent = `${currentPage + 1} / ${imageLinks.length}`;
|
||
updateHistory();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* direction: +1 = story-forward, -1 = story-backward
|
||
* (caller already accounts for RTL when mapping clicks/keys)
|
||
*/
|
||
function changePage(direction_) {
|
||
if (layoutMode === "scroll") {
|
||
// In scroll mode, arrow keys scroll by one viewport height
|
||
container.scrollBy({ top: direction_ * window.innerHeight * 0.9, behavior: "smooth" });
|
||
return;
|
||
}
|
||
|
||
const step = layoutMode === "double" ? 2 : 1;
|
||
const newPage = currentPage + direction_ * step;
|
||
|
||
if (newPage < 0) {
|
||
handleChapterEdge(-1);
|
||
return;
|
||
}
|
||
if (newPage >= imageLinks.length) {
|
||
handleChapterEdge(1);
|
||
return;
|
||
}
|
||
|
||
currentPage = newPage;
|
||
updatePagination();
|
||
updateFooter();
|
||
}
|
||
|
||
function handleChapterEdge(storyDir) {
|
||
// storyDir: +1 = next chapter, -1 = prev chapter
|
||
if (storyDir > 0) {
|
||
const next = allChapters[currentChapterIndex + 1];
|
||
if (next) {
|
||
loadChapter(next.id);
|
||
} else {
|
||
showToast("You've reached the last chapter!");
|
||
}
|
||
} else {
|
||
const prev = allChapters[currentChapterIndex - 1];
|
||
if (prev) {
|
||
loadChapter(prev.id);
|
||
} else {
|
||
showToast("This is the first chapter!");
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleUI() {
|
||
header.classList.toggle("hidden");
|
||
footer.classList.toggle("hidden");
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// TOAST
|
||
// ═══════════════════════════════════════════════════════════
|
||
function showToast(msg, duration = 2800) {
|
||
toast.textContent = msg;
|
||
toast.classList.remove("hidden");
|
||
clearTimeout(toastTimeout);
|
||
toastTimeout = setTimeout(() => toast.classList.add("hidden"), duration);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// CONTROLS SETUP (called once)
|
||
// ═══════════════════════════════════════════════════════════
|
||
function setupControls() {
|
||
|
||
// ── Layout switcher ──
|
||
layoutScrollBtn.addEventListener("click", () => setLayout("scroll"));
|
||
layoutSingleBtn.addEventListener("click", () => setLayout("single"));
|
||
layoutDoubleBtn.addEventListener("click", () => setLayout("double"));
|
||
|
||
// ── Fit switcher ──
|
||
fitWidthBtn.addEventListener("click", () => setFit("fit-width"));
|
||
fitHeightBtn.addEventListener("click", () => setFit("fit-height"));
|
||
fitBothBtn.addEventListener("click", () => setFit("fit-both"));
|
||
|
||
// ── Direction ──
|
||
directionBtn.addEventListener("click", () => {
|
||
setDirection(direction === "ltr" ? "rtl" : "ltr");
|
||
});
|
||
|
||
// ── Zoom buttons ──
|
||
zoomOutBtn.addEventListener("click", () => applyZoom(zoomLevel - ZOOM_STEP));
|
||
zoomInBtn.addEventListener("click", () => applyZoom(zoomLevel + ZOOM_STEP));
|
||
|
||
// ── Ctrl+Scroll / Pinch zoom ──
|
||
container.addEventListener("wheel", (e) => {
|
||
if (e.ctrlKey) {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||
applyZoom(zoomLevel + delta);
|
||
}
|
||
}, { passive: false });
|
||
|
||
// ── Scroll listener (scroll mode progress) ──
|
||
container.addEventListener("scroll", () => {
|
||
if (layoutMode === "scroll") updateScrollProgress();
|
||
});
|
||
|
||
// ── Chapter panel ──
|
||
chaptersBtn.addEventListener("click", openPanel);
|
||
closePanelBtn.addEventListener("click", closePanel);
|
||
panelBackdrop.addEventListener("click", closePanel);
|
||
|
||
// ── Nav overlay (paged modes) ──
|
||
// In RTL mode, the visual left/right clicks are swapped in story direction
|
||
navLeft.addEventListener("click", () => {
|
||
const storyDir = direction === "rtl" ? 1 : -1;
|
||
changePage(storyDir);
|
||
});
|
||
navRight.addEventListener("click", () => {
|
||
const storyDir = direction === "rtl" ? -1 : 1;
|
||
changePage(storyDir);
|
||
});
|
||
navCenter.addEventListener("click", toggleUI);
|
||
|
||
// ── Progress bar click (paged modes) ──
|
||
pageProgress.addEventListener("click", (e) => {
|
||
if (imageLinks.length === 0) return;
|
||
if (layoutMode === "scroll") {
|
||
const ratio = e.offsetX / pageProgress.offsetWidth;
|
||
const scrollableH = container.scrollHeight - container.clientHeight;
|
||
container.scrollTo({ top: ratio * scrollableH, behavior: "smooth" });
|
||
} else {
|
||
const ratio = e.offsetX / pageProgress.offsetWidth;
|
||
const target = Math.floor(ratio * imageLinks.length);
|
||
const snapped = layoutMode === "double" ? target - (target % 2) : target;
|
||
currentPage = Math.max(0, Math.min(snapped, imageLinks.length - 1));
|
||
updatePagination();
|
||
updateFooter();
|
||
}
|
||
});
|
||
|
||
// ── Keyboard ──
|
||
document.addEventListener("keydown", (e) => {
|
||
// Don't fire if user is typing somewhere
|
||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||
|
||
const rtl = direction === "rtl" && layoutMode !== "scroll";
|
||
|
||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
changePage(rtl ? -1 : 1);
|
||
}
|
||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
changePage(rtl ? 1 : -1);
|
||
}
|
||
if (e.key === " " && !e.shiftKey) {
|
||
e.preventDefault();
|
||
changePage(1);
|
||
}
|
||
if (e.key === " " && e.shiftKey) {
|
||
e.preventDefault();
|
||
changePage(-1);
|
||
}
|
||
if (e.key === "Escape" && panelOpen) {
|
||
closePanel();
|
||
}
|
||
if (e.key === "f" || e.key === "F") {
|
||
toggleUI();
|
||
}
|
||
});
|
||
|
||
// ── Resize: re-snap scroll position so paged modes stay aligned ──
|
||
let resizeTimer = null;
|
||
window.addEventListener("resize", () => {
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => {
|
||
if (layoutMode !== "scroll" && imageLinks.length > 0) {
|
||
updatePagination();
|
||
}
|
||
}, 50);
|
||
});
|
||
|
||
// ── Back button ──
|
||
backBtn.addEventListener("click", () => {
|
||
if (window.parent && window.parent !== window) {
|
||
window.parent.postMessage("close-reader-modal", "*");
|
||
} else if (window.history.length > 1) {
|
||
window.history.back();
|
||
} else {
|
||
window.location.href = "/";
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Kick it off ──
|
||
init();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |