La página sigue al dedo al deslizar, sonido de papel al pasar página y arreglos de la hoja en doble página

- Arrastrar con el dedo pasa la página siguiendo el toque (hoja, deslizar o fundido); al soltar termina
  de pasar o vuelve a su sitio según lo recorrido y el impulso.
- Sonido de roce de papel sintetizado en el navegador, al azar entre 6 variantes; volumen o sin sonido
  en las opciones del lector.
- Doble página hacia atrás: se precargan también las 2 páginas anteriores, el lado de las páginas nuevas
  sale del orden de lectura y, si aún no han cargado, se desliza en vez de pasar la hoja.
- Safari: sin líneas entre las tiras de la hoja (sombreado con filtro en la propia cara y más solape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 15:46:56 -06:00
co-authored by Claude Opus 5.5
parent e66b5fea95
commit 84a60a6501
3 changed files with 238 additions and 30 deletions
+3 -1
View File
@@ -26,7 +26,9 @@ Lector web de manga autoalojado, con las fuentes de
- **Lector**: una página, doble página (tablet horizontal/PC; portada y páginas apaisadas van solas) - **Lector**: una página, doble página (tablet horizontal/PC; portada y páginas apaisadas van solas)
o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en o vertical. Modo, ajuste y dirección se recuerdan por manga (por defecto: manhwa/manhua en
vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro, vertical, manga por página de derecha a izquierda). Zoom con pellizco, doble toque en el centro,
Ctrl+rueda o + / − / 0. Ctrl+rueda o + / − / 0. Al pasar página la hoja se dobla como papel y, al deslizar, sigue al dedo;
suena un roce de papel elegido al azar entre 6 variantes sintetizadas en el navegador (volumen o
sin sonido en las opciones del lector).
- **Reescalado HD**: en la ficha de un manga descargado, "Reescalar a HD" pasa sus capítulos por - **Reescalado HD**: en la ficha de un manga descargado, "Reescalar a HD" pasa sus capítulos por
Real-ESRGAN (`realesr-animevideov3` ×2, en la GPU por Vulkan) y guarda la versión HD en Real-ESRGAN (`realesr-animevideov3` ×2, en la GPU por Vulkan) y guarda la versión HD en
`<Título>/.hd/` (carpeta oculta: el CBZ original no se toca y Kavita/Komga no ven duplicados). `<Título>/.hd/` (carpeta oculta: el CBZ original no se toca y Kavita/Komga no ven duplicados).
+234 -27
View File
@@ -1186,6 +1186,99 @@ async function viewManga(id, langOverride) {
// ---------- Lector ---------- // ---------- Lector ----------
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } 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' } const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
// ---------- Sonido al pasar página ----------
// Seis variantes de roce de papel sintetizadas al momento (sin archivos de audio): ruido filtrado cuyo tono
// sube con la velocidad de la hoja (el aire que mueve), chasquidos sueltos (las fibras del papel) y un golpe
// sordo al caer. Cada vez sale una variante distinta de la anterior, con pequeñas variaciones de duración y
// tono para que no suene nunca igual.
const TURN_SOUNDS = [
{ dur: 0.30, f: [1700, 3600], q: 0.9, peak: 0.55, crackle: 70, thump: 0.25 }, // hoja fina y rápida
{ dur: 0.50, f: [800, 2100], q: 0.7, peak: 0.6, crackle: 45, thump: 0.6 }, // hoja gruesa, lenta
{ dur: 0.36, f: [2300, 5000], q: 1.1, peak: 0.5, crackle: 240, thump: 0.15 }, // papel crujiente
{ dur: 0.42, f: [1200, 2500], q: 0.5, peak: 0.45, crackle: 18, thump: 0 }, // roce suave
{ dur: 0.50, f: [1400, 3100], q: 0.8, peak: 0.6, crackle: 110, thump: 0.4, twoStep: true }, // se despega y cae
{ dur: 0.26, f: [2800, 5800], q: 1.4, peak: 0.4, crackle: 160, thump: 0.12, snap: true }, // satinado, con chasquido
]
function synthTurn(p, sr, rnd = Math.random) {
const n = Math.round(sr * (p.dur + 0.1))
const out = new Float32Array(n)
// Filtro paso banda (biquad RBJ, ganancia 1 en el centro).
const band = () => {
let x1 = 0, x2 = 0, y1 = 0, y2 = 0, b0 = 0, a1 = 0, a2 = 0
return {
set(f, q) { const w = 2 * Math.PI * Math.min(f, sr * 0.45) / sr, al = Math.sin(w) / (2 * q), a0 = 1 + al; b0 = al / a0; a1 = -2 * Math.cos(w) / a0; a2 = (1 - al) / a0 },
run(x) { const y = b0 * (x - x2) - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y },
}
}
const air = band(), fiber = band(), body = band()
fiber.set(4200, 0.8)
body.set(170, 0.9)
const a = Math.log(0.5) / Math.log(p.peak) // sin(π·x^a) tiene su máximo en x = peak
const swell = x => x <= 0 || x >= 1 ? 0 : Math.sin(Math.PI * x ** a) ** 2
const env = x => p.twoStep ? Math.max(0.75 * swell(Math.min(1, x / 0.38)), swell((x - 0.25) / 0.75)) : swell(x)
let flutter = 0, grain = 0, grainK = 1, snapAt = p.snap ? Math.round(sr * p.dur * 0.12) : -1
const thumpAt = Math.round(sr * p.dur * 0.86), thumpK = Math.exp(-1 / (0.035 * sr))
let thump = 0
for (let i = 0; i < n; i++) {
const x = i / (sr * p.dur), e = env(x), w = rnd() * 2 - 1
if (i % 32 === 0) air.set(p.f[0] + (p.f[1] - p.f[0]) * e, p.q)
flutter += (w - flutter) * 0.004 // temblor lento del volumen
let s = air.run(w) * e * (1 + 1.8 * flutter)
// Chasquidos: más frecuentes cuanto más rápido se mueve la hoja.
if (rnd() < (p.crackle * (0.15 + e)) / sr) { grain = Math.max(grain, (0.3 + 0.7 * rnd()) * (0.3 + e)); grainK = Math.exp(-1 / ((0.0006 + 0.0025 * rnd()) * sr)) }
if (i === snapAt) { grain = 1.6; grainK = Math.exp(-1 / (0.004 * sr)) }
grain *= grainK
s += fiber.run(w) * grain * 1.4
if (i === thumpAt) thump = p.thump
thump *= thumpK
s += body.run(w) * thump * 3
out[i] = s
}
let peak = 0
for (const v of out) peak = Math.max(peak, Math.abs(v))
const fade = Math.round(sr * 0.01)
for (let i = 0; i < n; i++) out[i] = (out[i] / (peak || 1)) * Math.min(1, (n - i) / fade)
return out
}
const SOUND_LEVELS = { off: ['Sin sonido', 0], low: ['Sonido bajo', 0.15], mid: ['Sonido medio', 0.3], high: ['Sonido alto', 0.55] }
const sfx = { ctx: null, last: -1 }
// El contexto de audio solo arranca dentro de un gesto del usuario (iOS): se prepara al tocar el lector.
function sfxUnlock() {
try {
const AC = window.AudioContext || window.webkitAudioContext
if (!AC) return null
sfx.ctx ??= new AC()
if (sfx.ctx.state !== 'running') sfx.ctx.resume().catch(() => {})
return sfx.ctx
} catch { return null }
}
// pan: de -1 (izquierda) a 1 (derecha), el lado por el que se mueve la hoja.
function turnSound(level, pan = 0) {
const vol = SOUND_LEVELS[level]?.[1]
if (!vol || document.hidden) return
const ctx = sfxUnlock()
if (!ctx) return
try {
let i
do i = Math.floor(Math.random() * TURN_SOUNDS.length); while (i === sfx.last)
sfx.last = i
const p = TURN_SOUNDS[i], k = 0.88 + Math.random() * 0.24
const data = synthTurn({ ...p, dur: p.dur * (0.88 + Math.random() * 0.24), f: p.f.map(f => f * k) }, ctx.sampleRate)
const buf = ctx.createBuffer(1, data.length, ctx.sampleRate)
buf.getChannelData(0).set(data)
const src = ctx.createBufferSource()
src.buffer = buf
const gain = ctx.createGain()
gain.gain.value = vol * (0.85 + Math.random() * 0.3)
src.connect(gain)
if (pan && ctx.createStereoPanner) {
const sp = ctx.createStereoPanner()
sp.pan.value = pan
gain.connect(sp).connect(ctx.destination)
} else gain.connect(ctx.destination)
src.start()
} catch {}
}
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga). // Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
function defaultPrefs(manga) { function defaultPrefs(manga) {
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' } if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
@@ -1200,7 +1293,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const lang = store.get(`lang:${mangaId}`, me.lang) const lang = store.get(`lang:${mangaId}`, me.lang)
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor). // Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', ...store.get('readerSettings', {}) } const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) }
const [info, chap] = await Promise.all([ const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
@@ -1250,6 +1343,7 @@ async function viewReader(mangaId, chapterId, startPage) {
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select> <select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select> <select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
<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="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> <select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
<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> <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>'} ${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
@@ -1318,14 +1412,15 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
return [i] return [i]
} }
// Las 4 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso).
function preload() { function preload() {
for (let k = 1; k <= 4; k++) { const load = i => {
const i = shown.at(-1) + k
if (i >= total) break
const im = new Image() const im = new Image()
im.onload = () => isWide(im) && wide.add(i) im.onload = () => isWide(im) && wide.add(i)
im.src = pages[i] im.src = pages[i]
} }
for (let k = 1; k <= 4 && shown.at(-1) + k < total; k++) load(shown.at(-1) + k)
for (let k = 1; k <= 2 && shown[0] - k >= 0; k++) load(shown[0] - k)
} }
// dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…). // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
function showPaged(dir = 0) { function showPaged(dir = 0) {
@@ -1335,7 +1430,9 @@ async function viewReader(mangaId, chapterId, startPage) {
stage.className = 'stage' stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2) stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('') stage.innerHTML = shown.map(i => `<img data-i="${i}" src="${pages[i]}" alt="" draggable="false">`).join('')
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir) // Al arrastrar, el sonido sale al soltar (si la página llega a pasar).
if (old && dir && !wantDrag) turnSound(settings.sfx, turnPan(dir))
if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag)
else { box.replaceChildren(stage) } else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => { stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i) const i = Number(img.dataset.i)
@@ -1350,7 +1447,10 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
// --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima; // --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
// al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya. // al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
let turning = null // { anims, cleanup } let turning = null // { anims, cleanup } (y move/release si sigue al dedo)
let wantDrag = false
// Lado por el que se mueve la hoja: el de la página que pasa (más marcado en doble página).
const turnPan = dir => (settings.dir === 'rtl' ? -1 : 1) * dir * (shown.length === 2 ? 0.3 : 0.12)
function finishTurn() { function finishTurn() {
if (!turning) return if (!turning) return
const t = turning const t = turning
@@ -1380,6 +1480,9 @@ async function viewReader(mangaId, chapterId, startPage) {
function paperCurl(r, frontSrc, backSrc, spineLeft) { function paperCurl(r, frontSrc, backSrc, spineLeft) {
const N = Math.max(10, Math.min(22, Math.round(r.w / 24))) const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
const w = r.w / N const w = r.w / N
// Cada cara se sale OV px por los lados de su tira para tapar la unión con la vecina: sin solape,
// Safari deja una rendija oscura entre tiras.
const OV = 1.5
const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
const el = document.createElement('div') const el = document.createElement('div')
el.className = 'curl' el.className = 'curl'
@@ -1391,10 +1494,13 @@ async function viewReader(mangaId, chapterId, startPage) {
const s = document.createElement('div') const s = document.createElement('div')
s.className = 'strip' s.className = 'strip'
Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' }) Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
const img = (src, col) => `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${-col * w}px">` // Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última
s.innerHTML = `<div class="sf front">${img(frontSrc, fc)}<i></i></div><div class="sf back${backSrc ? '' : ' paper'}">${backSrc ? img(backSrc, bc) : ''}<i></i></div>` // columna). El reverso está girado sobre su centro: allí su izquierda es la derecha de la tira.
const l = fc > 0 ? OV : 0, rr = fc < N - 1 ? OV : 0
const face = (cls, src, col, off) => `<div class="sf ${cls}" style="left:${-l}px;right:${-rr}px">${src ? `<img src="${src}" alt="" style="width:${r.w}px;height:${r.h}px;left:${off - col * w}px">` : ''}</div>`
s.innerHTML = face('front', frontSrc, fc, l) + face(backSrc ? 'back' : 'back paper', backSrc, bc, rr)
parent.append(s) parent.append(s)
strips.push({ s, f: s.firstChild.lastChild, b: s.children[1].lastChild }) strips.push({ s, f: s.children[0], b: s.children[1] })
parent = s parent = s
} }
const step = spineLeft ? w : -w const step = spineLeft ? w : -w
@@ -1417,8 +1523,10 @@ async function viewReader(mangaId, chapterId, startPage) {
t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)` t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)`
prev = a prev = a
const c = Math.cos((a * Math.PI) / 180) const c = Math.cos((a * Math.PI) / 180)
t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse // Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari
t.b.style.opacity = ((1 + c) * 0.3).toFixed(3) // cada capa se compone aparte y en las uniones entre tiras asomaban líneas.
t.f.style.filter = `brightness(${(1 - (1 - c) * 0.3).toFixed(3)})`
t.b.style.filter = `brightness(${(1 - (1 + c) * 0.3).toFixed(3)})`
lift = Math.max(lift, Math.sin((a * Math.PI) / 180)) lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
}) })
return lift return lift
@@ -1432,9 +1540,22 @@ async function viewReader(mangaId, chapterId, startPage) {
while (g1 > g0 && !seen(g1)) g1 -= 0.01 while (g1 > g0 && !seen(g1)) g1 -= 0.01
return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)] return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
} }
return { el, set, span } // Posición del borde libre (fracción del ancho desde el lomo: 1 = en su sitio, -1 = pasada del todo)
// y el avance g que la pone donde está el dedo.
const edge = (g, reverse) => angles(g, reverse).reduce((sum, a) => sum + Math.cos((a * Math.PI) / 180), 0) / N
function solve(target, reverse) {
let lo = 0, hi = 1
for (let k = 0; k < 18; k++) {
const m = (lo + hi) / 2
if ((edge(m, reverse) > target) !== reverse) lo = m
else hi = m
} }
function turnPage(old, stage, dir) { return (lo + hi) / 2
}
return { el, set, span, solve }
}
// drag: la página sigue al dedo; devuelve el control (move/release) en vez de animarse sola.
function turnPage(old, stage, dir, drag = false) {
// "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq. // "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
const fitWidth = box.classList.contains('fit-width') const fitWidth = box.classList.contains('fit-width')
@@ -1449,15 +1570,22 @@ async function viewReader(mangaId, chapterId, startPage) {
const double = oldImgs.length === 2 && shown.length === 2 const double = oldImgs.length === 2 && shown.length === 2
// Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada. // Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide' if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
const anims = [] // En doble página, el reverso de la hoja es una página nueva: sin cargar, se verían solo las sombras
// de las tiras (líneas oscuras).
if (kind === 'flip' && double && ![...stage.querySelectorAll('img')].every(ready)) kind = 'slide'
const extra = [] const extra = []
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()) } const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' }
const ease = 'cubic-bezier(.2,.7,.2,1)' // Al soltar el dedo sin pasar la página: queda la vieja como estaba.
const revert = () => { stage.remove(); extra.forEach(e => e.remove()); old.classList.remove('leaving'); Object.assign(old.style, { visibility: '', opacity: '', transform: '' }) }
const easeOut = t => 1 - (1 - t) ** 3
// step(g) dibuja el avance g; toG(q) convierte lo recorrido por el dedo (0-1 de range px) en avance.
let step, dur, range = box.clientWidth, toG = q => q, auto = easeOut, g0 = 0, g1 = 1
if (kind === 'fade') { if (kind === 'fade') {
anims.push(old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' })) step = g => { old.style.opacity = String(1 - g) }
dur = 180
} else if (kind === 'slide') { } else if (kind === 'slide') {
anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' })) step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` }
anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease })) dur = 260
} else { } else {
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue). // Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
// Medidas relativas al contenedor. // Medidas relativas al contenedor.
@@ -1471,7 +1599,9 @@ async function viewReader(mangaId, chapterId, startPage) {
// la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia // la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
// encima de la nueva) hasta que la hoja cae. // encima de la nueva) hasta que la hoja cae.
const oldX = byX(oldImgs) const oldX = byX(oldImgs)
const newX = byX(stage.querySelectorAll('img')) // Las nuevas puede que aún no estén cargadas (sin tamaño ni posición): su lado sale del orden de lectura.
const newX = [...stage.querySelectorAll('img')]
if (settings.dir === 'rtl') newX.reverse()
const front = sign > 0 ? oldX.at(-1) : oldX[0] const front = sign > 0 ? oldX.at(-1) : oldX[0]
const stay = sign > 0 ? oldX[0] : oldX.at(-1) const stay = sign > 0 ? oldX[0] : oldX.at(-1)
rect = rel(front) rect = rel(front)
@@ -1504,14 +1634,41 @@ async function viewReader(mangaId, chapterId, startPage) {
place(curl.el, rect) place(curl.el, rect)
box.append(drop, curl.el) box.append(drop, curl.el)
extra.push(drop, curl.el) extra.push(drop, curl.el)
const [g0, g1] = double ? [0, 1] : curl.span(reverse) ;[g0, g1] = double ? [0, 1] : curl.span(reverse)
anims.push(frameAnim(double ? 700 : 560, t => { step = g => { drop.style.opacity = (curl.set(g, reverse) * 0.8).toFixed(3) }
const lift = curl.set(g0 + (g1 - g0) * t, reverse) dur = double ? 700 : 560
drop.style.opacity = (lift * 0.8).toFixed(3) auto = t => g0 + (g1 - g0) * t
})) // El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble);
// hacia atrás en una página, del lomo a su sitio.
range = double ? 2 * rect.w : rect.w
toG = q => curl.solve(reverse ? q : 1 - q * (double ? 2 : 1), reverse)
} }
if (drag) {
let g = toG(0)
step(g)
const ctl = {
anims: [], cleanup, range,
move(q) { g = toG(Math.min(1, Math.max(0, q))); step(g) },
// Termina de pasar la página (commit) o la devuelve a su sitio; done(commit) al acabar.
release(commit, done) {
const from = g, to = commit ? g1 : g0
const a = frameAnim(Math.max(120, (dur * Math.abs(to - from)) / (g1 - g0 || 1)), t => { g = from + (to - from) * easeOut(t); step(g) })
ctl.anims = [a]
a.finished.then(() => {
if (turning !== ctl) return
turning = null
if (commit) cleanup()
else revert()
done(commit)
}, () => {})
},
}
turning = ctl
return
}
const anims = [frameAnim(dur, t => step(auto(t)))]
turning = { anims, cleanup } turning = { anims, cleanup }
Promise.all(anims.map(a => a.finished)).then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {}) anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
} }
function showEnd() { function showEnd() {
finishTurn() finishTurn()
@@ -1644,7 +1801,9 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
box.addEventListener('pointerdown', e => { box.addEventListener('pointerdown', e => {
if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return
if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página
if (e.pointerType === 'mouse' && e.button !== 0) return if (e.pointerType === 'mouse' && e.button !== 0) return
if (settings.sfx !== 'off') sfxUnlock()
pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (pts.size === 1) { if (pts.size === 1) {
tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false } tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false }
@@ -1659,7 +1818,35 @@ async function viewReader(mangaId, chapterId, startPage) {
box.addEventListener('pointermove', e => { box.addEventListener('pointermove', e => {
if (!pts.has(e.pointerId)) return if (!pts.has(e.pointerId)) return
pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) pts.set(e.pointerId, { x: e.clientX, y: e.clientY })
if (gesture?.type === 'turn') {
if (turning !== gesture.ctl) return
const d = (e.clientX - gesture.x0) * gesture.k
gesture.v = (d - gesture.d) / Math.max(1, e.timeStamp - gesture.t)
gesture.d = d
gesture.t = e.timeStamp
gesture.ctl.move(d / gesture.ctl.range)
return
}
if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true if (tap && Math.hypot(e.clientX - tap.x, e.clientY - tap.y) > 10) tap.moved = true
// Arrastre horizontal con un dedo: la página empieza a pasar y sigue al dedo.
if (tap && !gesture && pts.size === 1 && zoom.s === 1 && e.pointerType !== 'mouse' && settings.anim !== 'none') {
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]
finishTurn()
wantDrag = true
try { fwd ? forward() : backward() } finally { wantDrag = false }
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 }
turning.move(gesture.d / turning.range)
box.setPointerCapture?.(e.pointerId)
}
tap = null
}
}
}
if (gesture?.type === 'pinch' && pts.size >= 2) { if (gesture?.type === 'pinch' && pts.size >= 2) {
const [a, b] = [...pts.values()] const [a, b] = [...pts.values()]
const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 const d = Math.hypot(a.x - b.x, a.y - b.y), mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2
@@ -1678,6 +1865,24 @@ async function viewReader(mangaId, chapterId, startPage) {
}) })
const endPointer = e => { const endPointer = e => {
if (!pts.delete(e.pointerId)) return if (!pts.delete(e.pointerId)) return
if (gesture?.type === 'turn') {
// Se pasa si se ha llevado más de un 30 % o se ha soltado con impulso hacia delante.
const g = gesture
gesture = null
tap = null
if (turning !== g.ctl) return
const q = g.d / g.ctl.range
const commit = e.type === 'pointerup' && (g.v > 0.35 || (q > 0.3 && g.v > -0.15))
if (commit) turnSound(settings.sfx, turnPan(g.dir))
g.ctl.release(commit, ok => {
if (ok) return
shown = g.prev
page = g.prev[0]
setPageLabel()
scheduleSave()
})
return
}
if (pts.size === 1 && zoom.s > 1) { if (pts.size === 1 && zoom.s > 1) {
const [p] = [...pts.values()] const [p] = [...pts.values()]
gesture = { type: 'pan', x: p.x, y: p.y } gesture = { type: 'pan', x: p.x, y: p.y }
@@ -1707,7 +1912,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#zoomreset').addEventListener('click', resetZoom) $('#zoomreset').addEventListener('click', resetZoom)
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) }) $('#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 }) 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 savePrefs = () => { const savePrefs = () => {
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message)) .then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
@@ -1723,6 +1928,8 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() }) $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
$('#anim').value = settings.anim $('#anim').value = settings.anim
$('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() }) $('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
$('#sfx').value = SOUND_LEVELS[settings.sfx] ? settings.sfx : 'mid'
$('#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('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; 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() }) $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
+1 -2
View File
@@ -359,11 +359,10 @@ body.reading #netbanner { display: none; }
.paged.fit-width .stage.leaving { bottom: auto; } .paged.fit-width .stage.leaving { bottom: auto; }
.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; } .paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; } .paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
.paged .curl .sf { position: absolute; top: 0; bottom: 0; left: -0.5px; right: -0.5px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; } .paged .curl .sf { position: absolute; top: 0; bottom: 0; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; } .paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
.paged .curl .back { transform: rotateY(180deg); } .paged .curl .back { transform: rotateY(180deg); }
.paged .curl .back.paper { background: #e9e5dc; } .paged .curl .back.paper { background: #e9e5dc; }
.paged .curl .sf i { position: absolute; inset: 0; background: #000; opacity: 0; }
.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; } .paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); } .paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); } .paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }