const api = window.mangaApi; const $ = (id) => document.getElementById(id); const params = new URLSearchParams(location.search); const sessionId = params.get('sessionId') || ''; const state = { meta: null, chapters: [], chapterIds: new Set(), chapterPage: 0, chapterMaxPage: 1, chapterIndex: -1, manifestId: '', loadToken: 0, objectUrls: new Set(), observer: null, retentionObserver: null, queuedPages: [], activePages: 0, chapterNavigation: false, theme: 'dark' }; function errorText(result, fallback) { return result && result.error ? String(result.error) : fallback; } function setStatus(text, error) { const status = $('readerStatus'); status.textContent = text; status.classList.toggle('error', !!error); status.classList.remove('hidden'); } function applyTheme(theme) { state.theme = theme === 'light' ? 'light' : 'dark'; document.documentElement.dataset.uiTheme = state.theme; } function revokeImages() { for (const url of state.objectUrls) URL.revokeObjectURL(url); state.objectUrls.clear(); } function renderChapterList() { const list = $('chapterList'); list.textContent = ''; state.chapters.forEach((chapter, index) => { const button = document.createElement('button'); button.className = 'chapter-item'; button.textContent = chapter.label; button.title = chapter.group ? `${chapter.label} · ${chapter.group}` : chapter.label; button.classList.toggle('active', index === state.chapterIndex); button.addEventListener('click', () => openChapter(index)); list.appendChild(button); }); $('loadMoreBtn').classList.toggle('hidden', state.chapterPage >= state.chapterMaxPage); $('tocMeta').textContent = `已载入 ${state.chapters.length} 章`; } async function loadChapterPage(page) { const button = $('loadMoreBtn'); button.disabled = true; button.textContent = '加载中…'; const result = await api.chapters(sessionId, page); button.disabled = false; button.textContent = '加载更多章节'; if (!result || !result.ok || !result.data) { setStatus(errorText(result, '章节目录加载失败'), true); return false; } for (const chapter of result.data.items || []) { if (state.chapterIds.has(chapter.chapterId)) continue; state.chapterIds.add(chapter.chapterId); state.chapters.push(chapter); } state.chapterPage = result.data.page || page; state.chapterMaxPage = result.data.maxPage || state.chapterPage; renderChapterList(); return true; } async function ensureNextChapter() { if (state.chapterIndex + 1 < state.chapters.length) return true; if (state.chapterPage >= state.chapterMaxPage) return false; return loadChapterPage(state.chapterPage + 1); } function updateNavigation() { const hasPrevious = state.chapterIndex > 0; const mayHaveNext = state.chapterIndex + 1 < state.chapters.length || state.chapterPage < state.chapterMaxPage; $('prevChapterBtn').disabled = state.chapterNavigation || !hasPrevious; $('nextChapterBtn').disabled = state.chapterNavigation || !mayHaveNext; $('chapterEndNextBtn').classList.toggle('hidden', !mayHaveNext); renderChapterList(); } function bytesOf(value) { if (value instanceof Uint8Array) return value; if (value instanceof ArrayBuffer) return new Uint8Array(value); if (value && value.data) return Uint8Array.from(value.data); throw new Error('图片数据格式无效'); } function schedulePage(element, index, token) { if (element.dataset.state) return; element.dataset.state = 'queued'; state.queuedPages.push({ element, index, token, manifestId: state.manifestId }); pumpPages(); } function pumpPages() { while (state.activePages < 4 && state.queuedPages.length) { const job = state.queuedPages.shift(); if (job.token !== state.loadToken || !job.element.isConnected) continue; state.activePages++; loadPage(job).finally(() => { state.activePages--; pumpPages(); }); } } async function loadPage(job) { const { element, index, token } = job; element.dataset.state = 'loading'; element.dataset.placeholder = `第 ${index + 1} 页加载中…`; const result = await api.image(sessionId, job.manifestId, index); if (token !== state.loadToken || !element.isConnected) return; if (!result || !result.ok || !result.data) { element.dataset.state = 'error'; element.dataset.placeholder = `${errorText(result, '图片加载失败')},点击重试`; element.classList.add('error'); return; } let bytes; try { bytes = bytesOf(result.data.bytes); } catch (error) { element.dataset.state = 'error'; element.dataset.placeholder = `${error.message},点击重试`; element.classList.add('error'); return; } const url = URL.createObjectURL(new Blob([bytes], { type: result.data.mimeType })); state.objectUrls.add(url); const image = document.createElement('img'); image.alt = `第 ${index + 1} 页`; image.decoding = 'async'; image.addEventListener('load', () => { if (token !== state.loadToken) return; URL.revokeObjectURL(url); state.objectUrls.delete(url); element.style.minHeight = ''; element.classList.add('loaded'); element.dataset.state = 'loaded'; }, { once: true }); image.addEventListener('error', () => { URL.revokeObjectURL(url); state.objectUrls.delete(url); element.textContent = ''; element.classList.add('error'); element.classList.remove('loaded'); element.dataset.state = 'error'; element.dataset.placeholder = '图片解码失败,点击重试'; }, { once: true }); image.src = url; element.textContent = ''; element.appendChild(image); } function buildPages(count, token) { const stack = $('pageStack'); stack.textContent = ''; state.queuedPages = []; state.retentionObserver = new IntersectionObserver((entries) => { for (const entry of entries) { const page = entry.target; if (entry.isIntersecting || page.dataset.state !== 'loaded') continue; const height = Math.max(160, Math.ceil(page.getBoundingClientRect().height)); const image = page.querySelector('img'); if (image) image.remove(); page.style.minHeight = `${height}px`; page.classList.remove('loaded'); page.dataset.state = ''; page.dataset.placeholder = `第 ${Number(page.dataset.index) + 1} 页`; state.observer.observe(page); } }, { root: $('pageScroller'), rootMargin: '2600px 0px' }); state.observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; const index = Number(entry.target.dataset.index); schedulePage(entry.target, index, token); state.observer.unobserve(entry.target); } }, { root: $('pageScroller'), rootMargin: '1200px 0px' }); for (let index = 0; index < count; index++) { const page = document.createElement('div'); page.className = 'manga-page'; page.dataset.index = String(index); page.dataset.placeholder = `第 ${index + 1} 页`; page.addEventListener('click', () => { if (page.dataset.state !== 'error') return; page.classList.remove('error'); page.dataset.state = ''; schedulePage(page, index, token); }); stack.appendChild(page); state.observer.observe(page); state.retentionObserver.observe(page); } } async function openChapter(index) { const chapter = state.chapters[index]; if (!chapter) return; const token = ++state.loadToken; if (state.observer) state.observer.disconnect(); if (state.retentionObserver) state.retentionObserver.disconnect(); state.queuedPages = []; revokeImages(); state.manifestId = ''; state.chapterIndex = index; $('pageStack').textContent = ''; $('chapterTitle').textContent = chapter.label; $('chapterEndNextBtn').classList.add('hidden'); $('pageScroller').scrollTop = 0; setStatus(`正在加载「${chapter.label}」…`); updateNavigation(); const result = await api.chapterManifest(sessionId, chapter.chapterId); if (token !== state.loadToken) return; if (!result || !result.ok || !result.data) { setStatus(errorText(result, '章节加载失败'), true); return; } state.manifestId = result.data.manifestId; $('readerStatus').classList.add('hidden'); buildPages(result.data.pages, token); updateNavigation(); } async function nextChapter() { if (state.chapterNavigation) return; state.chapterNavigation = true; const currentIndex = state.chapterIndex; updateNavigation(); try { if (!await ensureNextChapter()) return; await openChapter(currentIndex + 1); } finally { state.chapterNavigation = false; updateNavigation(); } } async function init() { if (!sessionId) { setStatus('在线漫画会话参数缺失', true); return; } const [themeResult, metaResult] = await Promise.all([ api.getTheme(), api.meta(sessionId) ]); applyTheme(themeResult && themeResult.ok ? themeResult.data : 'dark'); if (!metaResult || !metaResult.ok || !metaResult.data) { setStatus(errorText(metaResult, '在线漫画会话已失效'), true); return; } state.meta = metaResult.data; $('bookTitle').textContent = state.meta.title; $('tocTitle').textContent = state.meta.title; $('qualityLabel').textContent = state.meta.quality === 'data' ? '原画质' : '压缩画质'; document.title = `${state.meta.title} · 在线漫画`; const loaded = await loadChapterPage(1); if (!loaded) return; if (!state.chapters.length) { setStatus('该漫画暂无可在线阅读的章节', true); return; } let initialIndex = state.meta.initialChapterId ? state.chapters.findIndex((chapter) => chapter.chapterId === state.meta.initialChapterId) : 0; while (initialIndex < 0 && state.chapterPage < state.chapterMaxPage) { if (!await loadChapterPage(state.chapterPage + 1)) return; initialIndex = state.chapters.findIndex( (chapter) => chapter.chapterId === state.meta.initialChapterId ); } openChapter(initialIndex < 0 ? 0 : initialIndex); } $('tocToggleBtn').addEventListener('click', () => $('tocPane').classList.toggle('collapsed')); $('tocCloseBtn').addEventListener('click', () => $('tocPane').classList.add('collapsed')); $('prevChapterBtn').addEventListener('click', () => openChapter(state.chapterIndex - 1)); $('nextChapterBtn').addEventListener('click', nextChapter); $('chapterEndNextBtn').addEventListener('click', nextChapter); $('loadMoreBtn').addEventListener('click', () => loadChapterPage(state.chapterPage + 1)); $('pageWidthRange').addEventListener('input', (event) => { const value = Math.max(480, Math.min(1400, Number(event.target.value) || 900)); document.documentElement.style.setProperty('--manga-page-width', `${value}px`); $('pageWidthLabel').textContent = `${value}px`; }); $('themeBtn').addEventListener('click', async () => { const result = await api.setTheme(state.theme === 'dark' ? 'light' : 'dark'); if (result && result.ok) applyTheme(result.data); }); $('minBtn').addEventListener('click', api.minimize); $('maxBtn').addEventListener('click', api.maximize); $('closeBtn').addEventListener('click', api.close); api.onThemeChanged(applyTheme); window.addEventListener('beforeunload', () => { if (state.observer) state.observer.disconnect(); if (state.retentionObserver) state.retentionObserver.disconnect(); revokeImages(); api.closeSession(sessionId).catch(() => {}); }); init().catch((error) => setStatus(error.message || String(error), true));