Modo libro 3D, tomos con cubiertas reales y ocultar créditos
Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta, hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor del libro. Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de modo que solo quedan sueltos los capítulos del final, y los fuera de orden se recolocan. Manhwa y manhua no tienen tomos. Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo si se repiten en otros capítulos de la obra. HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+443
-54
@@ -681,7 +681,7 @@ async function viewManga(id, langOverride) {
|
||||
if (cleanup) { cleanup(); cleanup = null }
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, hideCredits: creditsOn, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
const dlSet = new Set(downloaded)
|
||||
const newSet = new Set(newKeys.map(String))
|
||||
let isFollowing = following
|
||||
@@ -732,6 +732,7 @@ async function viewManga(id, langOverride) {
|
||||
<div class="drawer-head"><b>Opciones</b><button class="btn small" id="libclose" title="Cerrar" aria-label="Cerrar">${icon('x')}</button></div>
|
||||
<div class="drawer-opts">
|
||||
<button class="btn" id="follow"></button>
|
||||
<button class="btn" id="hidecred" hidden></button>
|
||||
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
|
||||
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
|
||||
${menuItems}
|
||||
@@ -1172,6 +1173,23 @@ async function viewManga(id, langOverride) {
|
||||
b.hidden = offline || isBook
|
||||
}
|
||||
renderFollow()
|
||||
// Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre.
|
||||
let hideCredits = !!creditsOn
|
||||
function renderCredits() {
|
||||
const b = $('#hidecred')
|
||||
if (!b) return
|
||||
b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos`
|
||||
b.classList.toggle('on', hideCredits)
|
||||
b.setAttribute('aria-pressed', hideCredits)
|
||||
b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.'
|
||||
b.hidden = offline || !vols.length
|
||||
}
|
||||
$('#hidecred').addEventListener('click', async () => {
|
||||
const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } })
|
||||
hideCredits = r.hideCredits
|
||||
renderCredits()
|
||||
toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas')
|
||||
})
|
||||
$('#follow').addEventListener('click', async () => {
|
||||
const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } })
|
||||
isFollowing = r.following
|
||||
@@ -1251,13 +1269,59 @@ async function viewManga(id, langOverride) {
|
||||
const d = new Date(e.t).toISOString().slice(0, 10)
|
||||
return `<span class="cdate${e.approx ? ' approx' : ''}" title="${e.approx ? 'Fecha estimada (entre capítulos con fecha conocida)' : 'Primera publicación conocida'}">${e.approx ? '≈ ' : ''}${fmtDate(d)}</span>`
|
||||
}
|
||||
// Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella.
|
||||
let vols = [], volOf = new Map(), folded = new Set()
|
||||
if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => {
|
||||
if (!r.volumes?.length || !ul?.isConnected) return
|
||||
vols = r.volumes
|
||||
volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v])))
|
||||
const open = volOf.get(progress?.chapter_id) || vols[0]
|
||||
folded = new Set(vols.filter(v => v !== open).map(v => v.vol))
|
||||
renderList()
|
||||
renderCredits()
|
||||
}, () => {})
|
||||
const volHead = v => {
|
||||
if (!v) return '<li class="volhead"><div class="voltoggle static"><b>Sin tomo</b></div></li>'
|
||||
const inVol = chapters.filter(c => volOf.get(c.id) === v)
|
||||
const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 }
|
||||
const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id)
|
||||
return `<li class="volhead"><button class="voltoggle" data-vol="${esc(v.vol)}" aria-expanded="${!folded.has(v.vol)}">
|
||||
${v.thumb ? `<img src="${esc(v.thumb)}" alt="" loading="lazy">` : ''}<span><b>Tomo ${esc(v.vol)}</b><span class="meta">${plural(inVol.length, 'capítulo')}${read ? ` · ${read} leídos` : ''}</span></span>
|
||||
</button><a class="btn small" href="${readUrl(id, from.id, r && !isDone(from) ? r.page : 0)}">${icon('book')} Leer tomo</a></li>`
|
||||
}
|
||||
ul?.addEventListener('click', e => {
|
||||
const b = e.target.closest('button.voltoggle')
|
||||
if (!b) return
|
||||
folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol)
|
||||
renderList()
|
||||
})
|
||||
function renderList() {
|
||||
if (!ul) return
|
||||
const list = desc ? [...chapters].reverse() : chapters
|
||||
const picking = sel?.mode === 'pick'
|
||||
const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
|
||||
pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : [])
|
||||
ul.classList.toggle('picking', picking)
|
||||
ul.innerHTML = list.map((c, i) => {
|
||||
// Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera.
|
||||
const seen = new Set()
|
||||
const grouped = !vols.length ? list : list.flatMap(c => {
|
||||
const v = volOf.get(c.id)
|
||||
if (!v) return [c]
|
||||
if (seen.has(v)) return []
|
||||
seen.add(v)
|
||||
return list.filter(x => volOf.get(x.id) === v)
|
||||
})
|
||||
let curVol
|
||||
ul.innerHTML = grouped.map((c, i) => {
|
||||
const v = volOf.get(c.id) || null
|
||||
const head = vols.length && v !== curVol ? volHead(v) : ''
|
||||
curVol = v
|
||||
if (v && !picking && folded.has(v.vol)) return head
|
||||
return head + chapterItem(c, i)
|
||||
}).join('')
|
||||
}
|
||||
let pickable = new Set()
|
||||
function chapterItem(c, i) {
|
||||
const picking = sel?.mode === 'pick'
|
||||
const r = readMap.get(c.id)
|
||||
const done = r && r.total_pages && r.page >= r.total_pages - 1
|
||||
if (picking) {
|
||||
@@ -1268,7 +1332,6 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : ''
|
||||
return `<li><a class="${cls}" href="${readUrl(id, c.id, r && !done ? r.page : (done ? 0 : null))}"><span>${esc(chapLabel(c))}</span><span class="meta">${chapMeta(c, r, done)}</span></a></li>`
|
||||
}).join('')
|
||||
}
|
||||
// Selección en la lista (Mayús + clic marca el tramo desde el último marcado).
|
||||
ul?.addEventListener('click', e => {
|
||||
@@ -1295,6 +1358,7 @@ async function viewManga(id, langOverride) {
|
||||
}
|
||||
|
||||
// ---------- Lector ----------
|
||||
const B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window
|
||||
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
|
||||
const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
|
||||
// ---------- Sonido al pasar página ----------
|
||||
@@ -1804,6 +1868,130 @@ async function createBook(text, mangaId, size) {
|
||||
return book
|
||||
}
|
||||
|
||||
// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción.
|
||||
async function pageSig(url) {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error('Sin imagen')
|
||||
const bmp = await createImageBitmap(await res.blob())
|
||||
const ar = bmp.width / bmp.height
|
||||
// A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome.
|
||||
let from = bmp, w = bmp.width, h = bmp.height, cv, cx
|
||||
while (w > 64 || h > 64 || w !== h) {
|
||||
w = Math.max(64, w >> 1)
|
||||
h = Math.max(64, h >> 1)
|
||||
cv = document.createElement('canvas')
|
||||
cv.width = w
|
||||
cv.height = h
|
||||
cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 })
|
||||
cx.imageSmoothingQuality = 'high'
|
||||
cx.drawImage(from, 0, 0, w, h)
|
||||
from = cv
|
||||
}
|
||||
bmp.close()
|
||||
const d = cx.getImageData(0, 0, 64, 64).data
|
||||
const out = new Uint8Array(256)
|
||||
for (let y = 0; y < 16; y++) {
|
||||
for (let x = 0; x < 16; x++) {
|
||||
let sum = 0
|
||||
for (let j = 0; j < 4; j++) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const k = ((y * 4 + j) * 64 + x * 4 + i) * 4
|
||||
sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2]
|
||||
}
|
||||
}
|
||||
out[y * 16 + x] = Math.round(sum / 16)
|
||||
}
|
||||
}
|
||||
return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 }
|
||||
}
|
||||
|
||||
// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra.
|
||||
async function creditPages(mangaId, parts) {
|
||||
const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length }))
|
||||
if (!chapters.length) return {}
|
||||
const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } })
|
||||
const first = await ask()
|
||||
if (!first.missing.length) return first.hidden
|
||||
const jobs = parts.filter(x => first.missing.includes(x.id))
|
||||
const sigs = {}
|
||||
let next = 0
|
||||
await Promise.all(Array.from({ length: 3 }, async () => {
|
||||
while (next < jobs.length) {
|
||||
const x = jobs[next++], n = x.urls.length
|
||||
try {
|
||||
const pages = {}
|
||||
for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i])
|
||||
sigs[x.id] = pages
|
||||
} catch {} // se volverá a intentar la próxima vez
|
||||
}
|
||||
}))
|
||||
return (await ask(sigs)).hidden
|
||||
}
|
||||
|
||||
// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo.
|
||||
async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) {
|
||||
const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))])
|
||||
const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes
|
||||
const vols = r?.volumes || []
|
||||
const at = vols.findIndex(v => v.chapters.includes(chapterId))
|
||||
if (at < 0) return null
|
||||
const v = vols[at]
|
||||
$('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`)
|
||||
const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}`
|
||||
const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id))
|
||||
const parts = new Array(want.length)
|
||||
let nextJob = 0
|
||||
await Promise.all(Array.from({ length: 4 }, async () => {
|
||||
while (nextJob < want.length) {
|
||||
const k = nextJob++, { c, i } = want[k]
|
||||
const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null)
|
||||
if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages }
|
||||
}
|
||||
}))
|
||||
const ok = parts.filter(Boolean)
|
||||
if (!ok.some(x => x.id === chapterId)) return null
|
||||
const cover = v.front ? await within(new Promise(res => {
|
||||
const im = new Image()
|
||||
im.onload = () => res(im.naturalWidth > 0)
|
||||
im.onerror = () => res(false)
|
||||
im.src = v.front
|
||||
}), 15000) : false
|
||||
// Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima.
|
||||
const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {}
|
||||
const pages = cover ? [v.front] : []
|
||||
const starts = new Set()
|
||||
for (const x of ok) {
|
||||
const out = new Set(hidden[x.id] || [])
|
||||
x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j))
|
||||
x.start = pages.length
|
||||
x.count = x.keep.length
|
||||
starts.add(x.start)
|
||||
pages.push(...x.keep.map(j => x.urls[j]))
|
||||
}
|
||||
const partOf = c => ok.find(x => x.i === c)
|
||||
const tome = {
|
||||
vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts,
|
||||
has: c => !!partOf(c),
|
||||
chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i },
|
||||
chapterStart: c => partOf(c).start,
|
||||
chapterPages: c => partOf(c).count,
|
||||
// El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última
|
||||
// visible es acabar el capítulo.
|
||||
rawCount: c => partOf(c).urls.length,
|
||||
rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] },
|
||||
localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at },
|
||||
// Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo.
|
||||
async refresh(c) {
|
||||
for (const x of ok) {
|
||||
if (x.local || x.i < c || x.i > c + 1) continue
|
||||
const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null)
|
||||
if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]]
|
||||
}
|
||||
},
|
||||
}
|
||||
return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null }
|
||||
}
|
||||
|
||||
async function viewReader(mangaId, chapterId, startPage) {
|
||||
document.body.classList.add('reading')
|
||||
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||||
@@ -1823,9 +2011,9 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const { manga, chapters } = info
|
||||
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
|
||||
const idx = chapters.findIndex(c => c.id === chapterId)
|
||||
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||||
const prev = idx > 0 ? chapters[idx - 1] : null
|
||||
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
|
||||
let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||||
let prev = idx > 0 ? chapters[idx - 1] : null
|
||||
let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
|
||||
const isPaged = () => settings.mode !== 'vertical'
|
||||
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
|
||||
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
|
||||
@@ -1844,7 +2032,23 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
if (location.hash !== opening) { book.release(); return }
|
||||
book.busy = () => turning
|
||||
}
|
||||
const pages = chap.pages
|
||||
// Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen
|
||||
// siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto.
|
||||
let tome = null, nextVol = null
|
||||
if (!isBook && settings.tomes !== false && idx >= 0) {
|
||||
const opening = location.hash
|
||||
const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits)
|
||||
if (location.hash !== opening) return
|
||||
if (loaded) {
|
||||
tome = loaded.tome
|
||||
const first = tome.parts[0].i, last = tome.parts.at(-1).i
|
||||
prev = first > 0 ? chapters[first - 1] : null
|
||||
next = last < chapters.length - 1 ? chapters[last + 1] : null
|
||||
nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo
|
||||
}
|
||||
}
|
||||
const seq = book || tome // las páginas cruzan varios capítulos
|
||||
const pages = tome ? tome.pages : chap.pages
|
||||
const pageSrc = book ? i => book.src(i) : i => pages[i]
|
||||
const total = book ? book.count : pages.length
|
||||
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
|
||||
@@ -1858,6 +2062,10 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
|
||||
bookResume = null
|
||||
}
|
||||
if (tome) {
|
||||
const local = tome.localPage(idx, Math.max(wanted, 0))
|
||||
wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local
|
||||
}
|
||||
let page = Math.min(Math.max(wanted, 0), total - 1)
|
||||
let shown = [page] // páginas visibles (2 en doble página)
|
||||
const lastShown = () => (isPaged() ? shown.at(-1) : page)
|
||||
@@ -1866,12 +2074,13 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
let saveTimer = null
|
||||
const bookPayload = (c, local, count) => ({
|
||||
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
|
||||
chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
|
||||
chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
|
||||
})
|
||||
const payload = () => {
|
||||
if (!book) return mangaPayload()
|
||||
const p = lastShown(), c = book.chapterAt(p)
|
||||
return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
|
||||
if (!seq) return mangaPayload()
|
||||
const p = lastShown(), c = seq.chapterAt(p)
|
||||
const local = Math.max(0, p - seq.chapterStart(c))
|
||||
return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c))
|
||||
}
|
||||
const mangaPayload = () => ({
|
||||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||||
@@ -1886,7 +2095,9 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
window.addEventListener('pagehide', beacon)
|
||||
saveNow()
|
||||
|
||||
let curChap = book ? book.chapterAt(page) : idx
|
||||
let curChap = seq ? seq.chapterAt(page) : idx
|
||||
const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0
|
||||
const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown())
|
||||
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||||
app.innerHTML = `
|
||||
<div class="reader">
|
||||
@@ -1905,6 +2116,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
${B3D_OK ? '<label class="row muted" style="font-size:.85rem" title="Muestra las páginas como un libro de verdad sobre una mesa"><input type="checkbox" id="b3dfx"> Libro 3D</label>' : ''}
|
||||
${isBook ? '' : '<label class="row muted" style="font-size:.85rem" title="Lee los capítulos de un tomo como un solo libro, con su portada"><input type="checkbox" id="tomefx"> Por tomos</label>'}
|
||||
<label class="row muted" style="font-size:.85rem" title="Brillo">☀ <input type="range" id="bright" min="40" max="120" step="5" style="width:90px"></label>
|
||||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
@@ -1916,16 +2129,18 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
${OFFLINE_OK && !isBook ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
<p class="muted">${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</div>
|
||||
<div id="b3d" hidden></div>
|
||||
<div id="pages"></div>
|
||||
<div class="rbar bottom">
|
||||
${book ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
${seq ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
<span id="pnum" class="muted"></span>
|
||||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||||
${book ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
${seq ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
const reader = $('.reader', app)
|
||||
let b3d = null, b3dLoading = false // el libro 3D, si está activo
|
||||
const box = $('#pages', app)
|
||||
const slider = $('#slider', app)
|
||||
$('#mode').value = settings.mode
|
||||
@@ -1944,14 +2159,15 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const applyFilter = () => {
|
||||
const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim()
|
||||
box.style.setProperty('--page-filter', f || 'none')
|
||||
b3d?.setFilter(f)
|
||||
reader.classList.toggle('bluelight', settings.filter === 'blue')
|
||||
}
|
||||
applyFilter()
|
||||
if ($('#saver')) $('#saver').checked = settings.saver
|
||||
if ($('#hd')) $('#hd').checked = settings.hd !== false
|
||||
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">${tome ? `Fin del tomo ${esc(tome.vol)}` : `Fin del capítulo ${esc(chapter.num ?? '')}`}</p>
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id, nextVol ? 0 : null)}">${icon('next')} Siguiente: ${nextVol ? `Tomo ${esc(nextVol.vol)}` : esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||||
|
||||
if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) {
|
||||
@@ -1969,28 +2185,31 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
const syncBookChapter = () => {
|
||||
const p = lastShown()
|
||||
book.trim(p)
|
||||
if (!isPaged()) {
|
||||
box.querySelectorAll('img[src]').forEach(im => {
|
||||
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
|
||||
})
|
||||
if (book) {
|
||||
book.trim(p)
|
||||
if (!isPaged()) {
|
||||
box.querySelectorAll('img[src]').forEach(im => {
|
||||
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
|
||||
})
|
||||
}
|
||||
}
|
||||
const c = book.chapterAt(p)
|
||||
const c = seq.chapterAt(p)
|
||||
if (c !== curChap) {
|
||||
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
|
||||
for (let j = curChap; j < c; j++) {
|
||||
const count = book.chapterPages(j)
|
||||
const count = tome ? tome.rawCount(j) : seq.chapterPages(j)
|
||||
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
curChap = c
|
||||
if (tome) { chapterId = chapters[c].id; chapter = chapters[c] }
|
||||
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
|
||||
}
|
||||
lastBookPage = p
|
||||
syncBookNav()
|
||||
}
|
||||
const setPageLabel = () => {
|
||||
if (book) syncBookChapter()
|
||||
if (seq) syncBookChapter()
|
||||
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
|
||||
slider.value = lastShown() + 1
|
||||
}
|
||||
@@ -2015,7 +2234,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
// ancha ocupa las dos caras.
|
||||
function spreadOf(i) {
|
||||
for (let s = 0; s < total;) {
|
||||
const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
|
||||
// En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre.
|
||||
const g = s === 0 || tome?.starts.has(s) || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1]
|
||||
if (g.includes(i)) return g
|
||||
s += g.length
|
||||
}
|
||||
@@ -2054,7 +2274,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const spread = spreadOf(i)
|
||||
return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l'
|
||||
}
|
||||
const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) })
|
||||
const refreshGutters = () => { box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }); if (b3dOn()) b3d?.show(shown) }
|
||||
// Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista.
|
||||
function isPan(old) {
|
||||
const imgs = old.querySelectorAll('img')
|
||||
@@ -2077,7 +2297,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
stage.classList.toggle('two', shown.length === 2)
|
||||
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pageSrc(i)}" alt="" draggable="false">`).join('')
|
||||
const imgs = [...stage.querySelectorAll('img')]
|
||||
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
|
||||
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté
|
||||
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
|
||||
const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página
|
||||
const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {})))
|
||||
@@ -2110,8 +2330,11 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
})
|
||||
stage.classList.toggle('loading', pending.length > 0)
|
||||
// Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
|
||||
if (old && dir && !wantDrag && !isPan(old)) turnSound(settings.sfx, turnPan(dir))
|
||||
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
|
||||
if (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir))
|
||||
if (b3dOn()) {
|
||||
box.replaceChildren(stage)
|
||||
syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag)
|
||||
} else if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
|
||||
else { box.replaceChildren(stage) }
|
||||
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
|
||||
const i = Number(img.dataset.i)
|
||||
@@ -2372,7 +2595,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
// giro daría un tirón. Una página que no llegó a subirse se sube al girar.
|
||||
function scheduleLeafTextures(delay = 300) {
|
||||
clearTimeout(leafTimer)
|
||||
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay)
|
||||
if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay)
|
||||
}
|
||||
function nextLeafTexture() {
|
||||
if (turning) return scheduleLeafTextures(400)
|
||||
@@ -2794,7 +3017,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
turning = { anims, cleanup }
|
||||
anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
|
||||
}
|
||||
function showEnd() {
|
||||
function showEnd(dir = 0) {
|
||||
finishTurn()
|
||||
showSeq++ // descarta una página que estuviera esperando a cargar
|
||||
clearTimeout(waitTimer)
|
||||
@@ -2802,12 +3025,14 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
atEnd = true
|
||||
resetZoom()
|
||||
box.innerHTML = endBlock
|
||||
reader.classList.remove('hide-ui')
|
||||
if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima
|
||||
else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir))
|
||||
syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag)
|
||||
}
|
||||
|
||||
function goTo(n, fromScroll = false, force = false, dir = 0) {
|
||||
if (isPaged()) {
|
||||
if (n >= total) return showEnd()
|
||||
if (n >= total) return showEnd(dir)
|
||||
n = Math.min(Math.max(n, 0), total - 1)
|
||||
const g = groupOf(n)
|
||||
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
|
||||
@@ -2816,7 +3041,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
shown = g
|
||||
page = g[0]
|
||||
setPageLabel()
|
||||
if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
|
||||
if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0)
|
||||
if (changed) scheduleSave()
|
||||
return
|
||||
}
|
||||
@@ -2830,7 +3055,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
|
||||
const backward = () => {
|
||||
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
|
||||
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return }
|
||||
if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
|
||||
}
|
||||
|
||||
@@ -2841,6 +3066,67 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('.rbar.bottom').classList.toggle('rtl', rtl)
|
||||
}
|
||||
|
||||
// --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos.
|
||||
const b3dWanted = () => B3D_OK && !!settings.book3d && isPaged() && settings.fit !== 'width'
|
||||
const b3dOn = () => b3dWanted() && !atEnd
|
||||
const b3dPaper = () => {
|
||||
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
|
||||
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
|
||||
}
|
||||
// El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve.
|
||||
function placeEnd(at, colors) {
|
||||
const el = $('.chapter-end', box)
|
||||
if (!el) return
|
||||
el.classList.toggle('on', !!at)
|
||||
if (!at) return
|
||||
const o = box.getBoundingClientRect()
|
||||
el.style.width = `${at.width}px`
|
||||
el.style.height = `${at.height}px`
|
||||
el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)`
|
||||
if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v)
|
||||
}
|
||||
function syncB3d(dir = 0, drag = false) {
|
||||
const on = b3dWanted(), host = $('#b3d', reader)
|
||||
if (!host) return
|
||||
reader.classList.toggle('b3d', on)
|
||||
host.hidden = !on
|
||||
if (!b3dWanted()) {
|
||||
b3d?.destroy()
|
||||
b3d = null
|
||||
return
|
||||
}
|
||||
// Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces.
|
||||
clearTimeout(leafTimer)
|
||||
for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex)
|
||||
leafTextures.clear()
|
||||
if (b3d) {
|
||||
if (!on) return
|
||||
const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag)
|
||||
if (ctl) turning = ctl
|
||||
return
|
||||
}
|
||||
if (b3dLoading) return
|
||||
b3dLoading = true
|
||||
import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => {
|
||||
b3dLoading = false
|
||||
if (!b3dWanted() || !box.isConnected) return
|
||||
b3d = m.createBook3D(host, {
|
||||
total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i),
|
||||
dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd,
|
||||
})
|
||||
if (!b3d) throw new Error('sin WebGL2')
|
||||
applyFilter()
|
||||
syncB3d()
|
||||
}).catch(err => {
|
||||
console.warn('Libro 3D:', err)
|
||||
b3dLoading = false
|
||||
settings.book3d = false
|
||||
saveGlobal()
|
||||
if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false
|
||||
syncB3d()
|
||||
toast('El libro 3D no está disponible en este dispositivo')
|
||||
})
|
||||
}
|
||||
function render() {
|
||||
observer?.disconnect()
|
||||
const keep = lastShown()
|
||||
@@ -2849,6 +3135,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
|
||||
renderModeQuick()
|
||||
box.dataset.dir = settings.dir
|
||||
syncB3d()
|
||||
// En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica).
|
||||
syncDirUi()
|
||||
$('#dir').hidden = !isPaged()
|
||||
@@ -2898,6 +3185,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const st = stageEl()
|
||||
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
|
||||
box.classList.toggle('zoomed', zoom.s > 1)
|
||||
if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y)
|
||||
$('#zoomreset').hidden = zoom.s === 1
|
||||
}
|
||||
function clampPan() {
|
||||
@@ -2930,7 +3218,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy }
|
||||
const xr = cx / window.innerWidth
|
||||
const center = xr > 0.3 && xr < 0.7
|
||||
if (dbl && (center || zoom.s > 1)) {
|
||||
if (dbl && !atEnd && (center || zoom.s > 1)) {
|
||||
clearTimeout(uiTimer)
|
||||
if (zoom.s > 1) resetZoom()
|
||||
else zoomAt(2.5, ...local(cx, cy))
|
||||
@@ -2943,15 +3231,24 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null }
|
||||
}
|
||||
box.addEventListener('pointerdown', e => {
|
||||
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
|
||||
if (!isPaged() || (atEnd && !b3dWanted()) || e.target.closest('a,button,select,input')) return
|
||||
// Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre.
|
||||
if (e.isPrimary && pts.size) {
|
||||
pts.clear()
|
||||
tap = null
|
||||
if (gesture?.type === 'turn') finishTurn()
|
||||
gesture = null
|
||||
}
|
||||
if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||
if (settings.sfx !== 'off') sfxUnlock()
|
||||
pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (pts.size === 1) {
|
||||
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
|
||||
tap = { x: e.clientX, y: e.clientY, t: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque
|
||||
gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null
|
||||
if (gesture) box.setPointerCapture?.(e.pointerId)
|
||||
} else if (pts.size === 2 && atEnd) {
|
||||
tap = null // la contraportada no se amplía
|
||||
} else if (pts.size === 2) {
|
||||
const [a, b] = [...pts.values()]
|
||||
gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 }
|
||||
@@ -2976,14 +3273,14 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
|
||||
if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) {
|
||||
const fwd = (dx < 0) === (settings.dir === 'ltr')
|
||||
if (fwd ? shown.at(-1) + 1 < total : page > 0) {
|
||||
const prev = [...shown]
|
||||
if (fwd ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) {
|
||||
const prev = [...shown], wasEnd = atEnd
|
||||
finishTurn()
|
||||
wantDrag = true
|
||||
grabY = tap.y
|
||||
try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null }
|
||||
if (turning?.move) {
|
||||
gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
|
||||
gesture = { type: 'turn', ctl: turning, prev, wasEnd, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp }
|
||||
turning.move(gesture.d / turning.range)
|
||||
box.setPointerCapture?.(e.pointerId)
|
||||
}
|
||||
@@ -3024,6 +3321,9 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
page = g.prev[0]
|
||||
setPageLabel()
|
||||
scheduleSave()
|
||||
if (g.wasEnd) return showEnd() // se quedó en la contraportada
|
||||
atEnd = false
|
||||
if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -3031,11 +3331,11 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const [p] = [...pts.values()]
|
||||
gesture = { type: 'pan', x: p.x, y: p.y }
|
||||
} else if (pts.size === 0) {
|
||||
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
|
||||
if (e.type === 'pointerup' && tap && !tap.moved && e.timeStamp - tap.t < 350) onTap(e.clientX, e.clientY)
|
||||
else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
|
||||
// Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
|
||||
const dx = e.clientX - tap.x, dy = e.clientY - tap.y
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
|
||||
}
|
||||
gesture = null
|
||||
tap = null
|
||||
@@ -3056,7 +3356,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('#zoomreset').addEventListener('click', resetZoom)
|
||||
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
|
||||
|
||||
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
|
||||
const saveGlobal = () => store.set('readerSettings', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d })
|
||||
const savePrefs = () => {
|
||||
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
|
||||
@@ -3083,26 +3383,42 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) })
|
||||
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
|
||||
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
|
||||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
||||
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
|
||||
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
|
||||
$('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
|
||||
$('#hdone')?.addEventListener('click', async e => {
|
||||
e.target.closest('button').disabled = true
|
||||
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
|
||||
})
|
||||
if ($('#tomefx')) {
|
||||
$('#tomefx').checked = settings.tomes !== false
|
||||
$('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() })
|
||||
}
|
||||
// Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte.
|
||||
const inSeq = c => !!book || !!tome?.has(c)
|
||||
$('#chap').addEventListener('change', e => {
|
||||
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
|
||||
const c = chapters.findIndex(x => x.id === e.target.value)
|
||||
if (seq && inSeq(c)) goTo(seq.chapterStart(c))
|
||||
else location.hash = readUrl(mangaId, e.target.value)
|
||||
})
|
||||
if (book) {
|
||||
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
|
||||
if (seq) {
|
||||
$('#bnext').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1))
|
||||
else location.hash = readUrl(mangaId, chapters[curChap + 1].id)
|
||||
})
|
||||
$('#bprev').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
|
||||
let c = curChap
|
||||
while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
|
||||
goTo(book.chapterStart(c))
|
||||
while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c--
|
||||
if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c))
|
||||
else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0)
|
||||
else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id)
|
||||
else goTo(seq.chapterStart(c))
|
||||
})
|
||||
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
|
||||
}
|
||||
if (book) {
|
||||
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) }
|
||||
}
|
||||
$('#offone')?.addEventListener('click', e => {
|
||||
const b = e.target.closest('button')
|
||||
@@ -3127,11 +3443,13 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
crop.ov.remove()
|
||||
window.removeEventListener('scroll', crop.draw)
|
||||
crop = null
|
||||
reader.classList.remove('b3dcrop')
|
||||
$('#clipbtn')?.classList.remove('on')
|
||||
}
|
||||
function startCrop() {
|
||||
if (crop) return endCrop()
|
||||
if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar')
|
||||
reader.classList.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal
|
||||
reader.classList.remove('hide-ui')
|
||||
$('.rpanel', reader).hidden = true
|
||||
const ov = document.createElement('div')
|
||||
@@ -3284,7 +3602,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
})
|
||||
const blob = await new Promise(r => cv.toBlob(r, 'image/png'))
|
||||
if (!blob) throw new Error('La zona es demasiado grande para este dispositivo')
|
||||
const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' })
|
||||
const at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter
|
||||
const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), cover: manga.cover || '' })
|
||||
const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||||
@@ -3305,6 +3624,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2]
|
||||
if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() }
|
||||
else if (e.key === backKey) backward()
|
||||
else if (atEnd) return
|
||||
else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy)
|
||||
else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy)
|
||||
else if (e.key === '0') resetZoom()
|
||||
@@ -3340,9 +3660,22 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
})
|
||||
}
|
||||
}
|
||||
if ($('#b3dfx')) {
|
||||
$('#b3dfx').checked = !!settings.book3d
|
||||
$('#b3dfx').addEventListener('change', e => {
|
||||
settings.book3d = e.target.checked
|
||||
saveGlobal()
|
||||
// iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar.
|
||||
if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {})
|
||||
render()
|
||||
})
|
||||
}
|
||||
render()
|
||||
cleanup = () => {
|
||||
b3d?.destroy()
|
||||
b3d = null
|
||||
book?.release()
|
||||
clearInterval(tomeTimer)
|
||||
freeLeafGL()
|
||||
endCrop()
|
||||
if (saveTimer) saveNow()
|
||||
@@ -3877,6 +4210,24 @@ async function viewStats() {
|
||||
}
|
||||
|
||||
// ---------- Ajustes ----------
|
||||
// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura.
|
||||
async function tableJpeg(file) {
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
const im = new Image()
|
||||
await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url })
|
||||
const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight))
|
||||
const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) })
|
||||
const g = c.getContext('2d')
|
||||
g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa
|
||||
g.fillRect(0, 0, c.width, c.height)
|
||||
g.imageSmoothingQuality = 'high'
|
||||
g.drawImage(im, 0, 0, c.width, c.height)
|
||||
const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88))
|
||||
if (!blob) throw new Error('No se pudo preparar la imagen')
|
||||
return blob
|
||||
} finally { URL.revokeObjectURL(url) }
|
||||
}
|
||||
function viewSettings() {
|
||||
app.innerHTML = `<h1>Ajustes</h1>
|
||||
<div class="settings">
|
||||
@@ -3887,6 +4238,13 @@ function viewSettings() {
|
||||
<section><b>${icon('book')} Qué mostrar</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.</p>
|
||||
<select id="contentmode">${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `<option value="${k}" ${k === contentMode() ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||||
<section><b>${icon('book')} Libro 3D: fondo de la mesa</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">Pon cualquier imagen en lugar de la madera. Se guarda en tu cuenta y se usa en todos tus dispositivos.</p>
|
||||
<img id="tableprev" class="tableprev" alt="Fondo actual de la mesa" hidden>
|
||||
<div class="row" style="flex-wrap:wrap;gap:8px">
|
||||
<label class="btn small">${icon('image')} Elegir imagen<input type="file" id="tablefile" accept="image/*" hidden></label>
|
||||
<button class="btn small danger" id="tabledel" hidden>${icon('trash')} Volver a la madera</button>
|
||||
</div></section>
|
||||
<section><b>Cambiar contraseña</b>
|
||||
<form id="pw" style="display:grid;gap:8px">
|
||||
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
|
||||
@@ -3921,6 +4279,37 @@ function viewSettings() {
|
||||
<div class="row"><button class="btn primary">${icon('check')} Guardar</button><button type="button" class="btn" id="ntest">${icon('send')} Enviar prueba</button></div>
|
||||
</form></section>
|
||||
</div>`
|
||||
const showTable = () => {
|
||||
const prev = $('#tableprev')
|
||||
if (!prev) return
|
||||
prev.hidden = !me.tableV
|
||||
if (me.tableV) prev.src = `/api/table?v=${me.tableV}`
|
||||
else prev.removeAttribute('src')
|
||||
$('#tabledel').hidden = !me.tableV
|
||||
}
|
||||
showTable()
|
||||
$('#tablefile').addEventListener('change', async e => {
|
||||
const f = e.target.files[0]
|
||||
e.target.value = ''
|
||||
if (!f) return
|
||||
try {
|
||||
const blob = await tableJpeg(f)
|
||||
const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||||
me.tableV = data.tableV
|
||||
showTable()
|
||||
toast('Fondo guardado')
|
||||
} catch (err) { toast(err.message) }
|
||||
})
|
||||
$('#tabledel').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/table', { method: 'DELETE' })
|
||||
me.tableV = 0
|
||||
showTable()
|
||||
toast('La mesa vuelve a ser de madera')
|
||||
} catch (err) { toast(err.message) }
|
||||
})
|
||||
api('/api/notify').then(n => {
|
||||
const f = $('#notify')
|
||||
if (!f) return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="sw-version" content="">
|
||||
<meta name="b3d-version" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#14121a">
|
||||
<title>Manga Reader</title>
|
||||
|
||||
@@ -79,6 +79,13 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.chapters a:hover { background: var(--surface-2); }
|
||||
.chapters a.read { opacity: .5; }
|
||||
.chapters a.current { outline: 1px solid var(--accent); opacity: 1; }
|
||||
.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.chapters .voltoggle.static { cursor: default; padding: 10px 12px; }
|
||||
.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; }
|
||||
.chapters .voltoggle > span { display: grid; gap: 2px; }
|
||||
.chapters .voltoggle .meta { text-align: left; }
|
||||
.chapters .volhead > .btn { flex: none; }
|
||||
.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; }
|
||||
|
||||
.history { display: grid; gap: 10px; }
|
||||
@@ -86,6 +93,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
|
||||
.settings { max-width: 480px; display: grid; gap: 16px; }
|
||||
.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; }
|
||||
.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); }
|
||||
.tableprev[hidden] { display: none; }
|
||||
|
||||
.loading { padding: 40px; text-align: center; color: var(--muted); }
|
||||
|
||||
@@ -450,3 +459,25 @@ body.reading #netbanner { display: none; }
|
||||
.tag.hot { color: var(--accent); }
|
||||
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } }
|
||||
.chapters .meta .cdate.approx { opacity: .7; }
|
||||
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
|
||||
#b3d { position: fixed; inset: 0; z-index: 0; }
|
||||
#b3d[hidden] { display: none; }
|
||||
#b3d canvas { display: block; width: 100%; height: 100%; }
|
||||
.reader.b3d { background: #4a2e19; }
|
||||
.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; }
|
||||
.reader.b3d > #pages img { visibility: hidden; }
|
||||
/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */
|
||||
.reader.b3d > #pages > * { pointer-events: none; }
|
||||
/* El final del capítulo, sobre la contraportada y con sus colores. */
|
||||
.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none);
|
||||
opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; }
|
||||
.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; }
|
||||
.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; }
|
||||
.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center;
|
||||
background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); }
|
||||
.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); }
|
||||
.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; }
|
||||
/* Para recortar se ve la página plana. */
|
||||
.reader.b3d.b3dcrop { background: #000; }
|
||||
.reader.b3d.b3dcrop > #b3d { display: none; }
|
||||
.reader.b3d.b3dcrop > #pages img { visibility: visible; }
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
|
||||
const SHELL = 'shell-v1'
|
||||
const OFFLINE = 'offline-v1'
|
||||
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
||||
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
||||
|
||||
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
|
||||
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
|
||||
|
||||
Reference in New Issue
Block a user