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:
+234
-27
@@ -1186,6 +1186,99 @@ async function viewManga(id, langOverride) {
|
||||
// ---------- Lector ----------
|
||||
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 ----------
|
||||
// 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).
|
||||
function defaultPrefs(manga) {
|
||||
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)
|
||||
// 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 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([
|
||||
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
|
||||
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="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="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>
|
||||
<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>'}
|
||||
@@ -1318,14 +1412,15 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
return [i]
|
||||
}
|
||||
// Las 4 siguientes y las 2 anteriores (para que al retroceder la hoja tenga ya su reverso).
|
||||
function preload() {
|
||||
for (let k = 1; k <= 4; k++) {
|
||||
const i = shown.at(-1) + k
|
||||
if (i >= total) break
|
||||
const load = i => {
|
||||
const im = new Image()
|
||||
im.onload = () => isWide(im) && wide.add(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…).
|
||||
function showPaged(dir = 0) {
|
||||
@@ -1335,7 +1430,9 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
stage.className = 'stage'
|
||||
stage.classList.toggle('two', shown.length === 2)
|
||||
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) }
|
||||
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
|
||||
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;
|
||||
// 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() {
|
||||
if (!turning) return
|
||||
const t = turning
|
||||
@@ -1380,6 +1480,9 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
function paperCurl(r, frontSrc, backSrc, spineLeft) {
|
||||
const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
|
||||
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 el = document.createElement('div')
|
||||
el.className = 'curl'
|
||||
@@ -1391,10 +1494,13 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const s = document.createElement('div')
|
||||
s.className = 'strip'
|
||||
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">`
|
||||
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>`
|
||||
// Solape hacia la izquierda y la derecha de la tira (no por fuera de la página, en la primera y la última
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
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)`
|
||||
prev = a
|
||||
const c = Math.cos((a * Math.PI) / 180)
|
||||
t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse
|
||||
t.b.style.opacity = ((1 + c) * 0.3).toFixed(3)
|
||||
// Se oscurece al inclinarse. Con un filtro en la propia cara y no con una capa negra encima: en Safari
|
||||
// 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))
|
||||
})
|
||||
return lift
|
||||
@@ -1432,9 +1540,22 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
while (g1 > g0 && !seen(g1)) g1 -= 0.01
|
||||
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
|
||||
}
|
||||
return (lo + hi) / 2
|
||||
}
|
||||
return { el, set, span, solve }
|
||||
}
|
||||
function turnPage(old, stage, dir) {
|
||||
// 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.
|
||||
const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
|
||||
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
|
||||
// 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'
|
||||
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 cleanup = () => { old.remove(); extra.forEach(e => e.remove()) }
|
||||
const ease = 'cubic-bezier(.2,.7,.2,1)'
|
||||
const cleanup = () => { old.remove(); extra.forEach(e => e.remove()); stage.style.transform = '' }
|
||||
// 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') {
|
||||
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') {
|
||||
anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' }))
|
||||
anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease }))
|
||||
step = g => { old.style.transform = `translateX(${-sign * 100 * g}%)`; stage.style.transform = `translateX(${sign * 100 * (1 - g)}%)` }
|
||||
dur = 260
|
||||
} else {
|
||||
// Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
|
||||
// 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
|
||||
// encima de la nueva) hasta que la hoja cae.
|
||||
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 stay = sign > 0 ? oldX[0] : oldX.at(-1)
|
||||
rect = rel(front)
|
||||
@@ -1504,14 +1634,41 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
place(curl.el, rect)
|
||||
box.append(drop, curl.el)
|
||||
extra.push(drop, curl.el)
|
||||
const [g0, g1] = double ? [0, 1] : curl.span(reverse)
|
||||
anims.push(frameAnim(double ? 700 : 560, t => {
|
||||
const lift = curl.set(g0 + (g1 - g0) * t, reverse)
|
||||
drop.style.opacity = (lift * 0.8).toFixed(3)
|
||||
}))
|
||||
;[g0, g1] = double ? [0, 1] : curl.span(reverse)
|
||||
step = g => { drop.style.opacity = (curl.set(g, reverse) * 0.8).toFixed(3) }
|
||||
dur = double ? 700 : 560
|
||||
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 }
|
||||
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() {
|
||||
finishTurn()
|
||||
@@ -1644,7 +1801,9 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
box.addEventListener('pointerdown', e => {
|
||||
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 (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 }
|
||||
@@ -1659,7 +1818,35 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
box.addEventListener('pointermove', e => {
|
||||
if (!pts.has(e.pointerId)) return
|
||||
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
|
||||
// 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) {
|
||||
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
|
||||
@@ -1678,6 +1865,24 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
})
|
||||
const endPointer = e => {
|
||||
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) {
|
||||
const [p] = [...pts.values()]
|
||||
gesture = { type: 'pan', x: p.x, y: p.y }
|
||||
@@ -1707,7 +1912,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 })
|
||||
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 = () => {
|
||||
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))
|
||||
@@ -1723,6 +1928,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
|
||||
$('#anim').value = settings.anim
|
||||
$('#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('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() })
|
||||
|
||||
@@ -359,11 +359,10 @@ body.reading #netbanner { display: none; }
|
||||
.paged.fit-width .stage.leaving { bottom: auto; }
|
||||
.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 .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 .back { transform: rotateY(180deg); }
|
||||
.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.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%); }
|
||||
|
||||
Reference in New Issue
Block a user