penguin/narou-android

Simple 縦書き reader for narou

commit 8d27af0137cb57d493beb8b9a6ec6dac8d102098

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-14T05:40:38Z
subjectFixed AccelerScroll not working on back
commit 8d27af0137cb57d493beb8b9a6ec6dac8d102098
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-14T05:40:38Z

    Fixed AccelerScroll not working on back
---
 CHANGELOG.md                                       |  3 ++
 app/build.gradle                                   |  2 +-
 app/src/main/assets/reader/content.js              | 36 +++++++++++++------
 app/src/main/assets/reader/manifest.json           |  2 +-
 .../net/astropenguin/naroureader/MainActivity.java |  6 ++++
 tests/reader.test.cjs                              | 40 ++++++++++++++++++++--
 6 files changed, 74 insertions(+), 15 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 503c66f..464b74d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,6 @@
+## Version 1.3.1
+— Fixed AccelerScroll not working on back
+
 ## Version 1.3.0 — language packs
 
 Reader controls and native Android dialogs now share bundled JSON strings in
diff --git a/app/build.gradle b/app/build.gradle
index 20796c3..b4ef8b1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,7 +9,7 @@ android {
         minSdk 26
         targetSdk 35
         versionCode 6
-        versionName '1.3.0'
+        versionName '1.3.1'
     }
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_17
diff --git a/app/src/main/assets/reader/content.js b/app/src/main/assets/reader/content.js
index 00f97a9..dc586c8 100644
--- a/app/src/main/assets/reader/content.js
+++ b/app/src/main/assets/reader/content.js
@@ -308,7 +308,7 @@
   const motion = new NarouMotion();
   let motionEnabled = false, motionSample = null, sampleAt = 0, lastFrame = 0;
   let neutral = null, motionRotation = null, touching = false, motionTimer, motionFrame;
-  let motionGeneration = 0, polling = false;
+  let motionGeneration = 0, polling = false, pageActive = true;
   function resetMotion() { motion.reset(); lastFrame = 0; }
   function updateCalibration(now = performance.now()) {
     const ready = motionEnabled && motionSample?.active && Number.isFinite(motionSample.x) && now - sampleAt <= 350 && neutral !== null;
@@ -340,7 +340,7 @@
   }
   function animateMotion(now) {
     motionFrame = null;
-    if (!motionEnabled || !opened || document.hidden) { resetMotion(); return; }
+    if (!motionEnabled || !opened || !pageActive || document.hidden) { resetMotion(); return; }
     const paused = (controlsVisible && !(motionDetails.open && calibrationRunning)) || touching || restoring || !motionSample?.active ||
       now - sampleAt > 350 || neutral === null || String(window.getSelection()).trim();
     if (paused) resetMotion();
@@ -358,13 +358,20 @@
     motionFrame = requestAnimationFrame(animateMotion);
   }
   async function pollMotion() {
-    if (polling || !motionEnabled || !opened || document.hidden) return;
+    if (polling || !motionEnabled || !opened || !pageActive || document.hidden) return;
     polling = true;
     const generation = motionGeneration;
+    let requestTimeout;
     try {
-      const sample = await browser.runtime.sendNativeMessage('narou', {
-        type: 'motion', enabled: true, moving: Math.abs(motion.velocity) > .1
-      });
+      // A native reply can be lost when Gecko freezes a history document.
+      const sample = await Promise.race([
+        browser.runtime.sendNativeMessage('narou', {
+          type: 'motion', enabled: true, open: opened, moving: Math.abs(motion.velocity) > .1
+        }),
+        new Promise((_, reject) => {
+          requestTimeout = setTimeout(() => reject(new Error('Sensor request timed out')), 1000);
+        })
+      ]);
       if (generation !== motionGeneration) return;
       if (!sample?.available) {
         motionEnabled = false; updateMotionButton();
@@ -394,18 +401,22 @@
 
 		setStatus('sensor.error', {detail});
 	} finally {
-      polling = false;
-      if (motionEnabled && opened && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
+      clearTimeout(requestTimeout);
+      // A reply from before pagehide must not unlock or schedule the new poller.
+      if (generation === motionGeneration) {
+        polling = false;
+        if (motionEnabled && opened && pageActive && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
+      }
     }
   }
   function startMotion() {
-    if (!motionEnabled || !opened || document.hidden) return;
+    if (!motionEnabled || !opened || !pageActive || document.hidden) return;
     if (!motionFrame) motionFrame = requestAnimationFrame(animateMotion);
     clearTimeout(motionTimer); pollMotion();
   }
   function stopMotion() {
     calibrationRunning = false;
-    motionGeneration++; clearTimeout(motionTimer); cancelAnimationFrame(motionFrame); motionFrame = null;
+    motionGeneration++; polling = false; clearTimeout(motionTimer); cancelAnimationFrame(motionFrame); motionFrame = null;
     motionSample = null; neutralButton.disabled = true; resetMotion();
     updateCalibration();
     notify({type: 'motion', enabled: false});
@@ -435,8 +446,11 @@
     touching = false;
     if (document.hidden) stopMotion(); else startMotion();
   });
-  window.addEventListener('pagehide', stopMotion);
+  window.addEventListener('pagehide', () => { pageActive = false; stopMotion(); });
   window.addEventListener('pageshow', () => {
+    pageActive = true;
+    touching = false;
+    resetMotion();
     if (opened) { notify({type: 'reader', open: true}); notify({type: 'theme', theme: settings.theme}); }
     startMotion();
   });
diff --git a/app/src/main/assets/reader/manifest.json b/app/src/main/assets/reader/manifest.json
index 59061dd..01753bd 100644
--- a/app/src/main/assets/reader/manifest.json
+++ b/app/src/main/assets/reader/manifest.json
@@ -1,7 +1,7 @@
 {
   "manifest_version": 2,
   "name": "\u7e26\u66f8\u304d Reader \u2014 Android",
-  "version": "1.3.0",
+  "version": "1.3.1",
   "description": "Built-in vertical reading view for Narou Reader.",
   "browser_specific_settings": {
     "gecko": {
diff --git a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
index da91c60..ab9dfcb 100644
--- a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
@@ -254,6 +254,12 @@ public final class MainActivity extends Activity {
                             !"ncode.syosetu.com".equals(Uri.parse(sender.url).getHost()) || !(message instanceof JSONObject)) return null;
                     JSONObject data = (JSONObject) message;
                     if ("motion".equals(data.optString("type"))) {
+                        // History restoration can race onPageStart's reading reset.
+                        // Active reader polls carry their current state so it recovers.
+                        if (data.optBoolean("enabled") && data.optBoolean("open")) {
+                            reading = true;
+                            menuButton.setVisibility(View.GONE);
+                        }
                         return GeckoResult.fromValue(tilt.sample(
                             data.optBoolean("enabled") && reading && resumed && hasWindowFocus(),
                             data.optBoolean("moving")));
diff --git a/tests/reader.test.cjs b/tests/reader.test.cjs
index d1e10f0..cf5c588 100644
--- a/tests/reader.test.cjs
+++ b/tests/reader.test.cjs
@@ -6,10 +6,18 @@ const ROOT=path.resolve(__dirname,'..');
  const context=await browser.newContext({viewport:{width:412,height:840},hasTouch:true});
  const data={},messages=[],errors=[];
  let sensor={available:true,active:true,x:0,rotation:0};
+ let holdNextSample=false, releaseSample=null;
  await context.exposeFunction('readStore',async keys=>keys===null?data:Object.fromEntries((Array.isArray(keys)?keys:[keys]).filter(k=>k in data).map(k=>[k,data[k]])));
  await context.exposeFunction('writeStore',async values=>Object.assign(data,values));
  await context.exposeFunction('removeStore',async keys=>keys.forEach(k=>delete data[k]));
- await context.exposeFunction('nativeMessage',async (_name,msg)=>{messages.push(msg);return msg.type==='motion'?{...sensor}:null;});
+ await context.exposeFunction('nativeMessage',async (_name,msg)=>{
+   messages.push(msg);
+   if(msg.type==='motion'&&msg.enabled&&holdNextSample){
+     holdNextSample=false;
+     return new Promise(resolve=>{releaseSample=resolve;});
+   }
+   return msg.type==='motion'?{...sensor}:null;
+ });
  await context.addInitScript(()=>{window.browser={storage:{local:{get:window.readStore,set:window.writeStore,remove:window.removeStore}},runtime:{getURL:path=>'https://ncode.syosetu.com/__reader/'+path,sendNativeMessage:window.nativeMessage,onMessage:{addListener:fn=>{window.toggleReader=fn;}}}};});
  const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
  const html='<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><h1 class="p-novel__title">縦書きのテスト</h1><div class="c-pager"><a href="https://ncode.syosetu.com/n0552ep/74/">前へ</a><a href="https://ncode.syosetu.com/n0552ep/">目次</a><a href="https://ncode.syosetu.com/n0552ep/76/">次へ</a></div><div class="p-novel__text">'+Array.from({length:100},(_,i)=>`<p> これは第${i+1}段落です。<ruby>読書<rt>どくしょ</rt></ruby>の時間を、静かに楽しみます。縦書きの列は右から左へ進みます。</p><p><br><br></p><p>  <span><br></span></p>`).join('')+'</div></body></html>';
@@ -174,7 +182,35 @@ const ROOT=path.resolve(__dirname,'..');
  const calibrated=await v.evaluate(e=>e.scrollLeft);
  await page.waitForTimeout(300);
  assert.equal(await v.evaluate(e=>e.scrollLeft),calibrated,'restored enabled state calibrates without jumping');
+ // Model a cached chapter returning while its previous native request is pending.
+ holdNextSample=true;
+ await page.waitForTimeout(200);
+ assert(releaseSample,'a sensor request is pending before navigating away');
+ await v.dispatchEvent('pointerdown',{clientX:1,clientY:200});
+ await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));
+ const hiddenPolls=messages.filter(m=>m.type==='motion'&&m.enabled).length;
+ await page.waitForTimeout(250);
+ assert.equal(messages.filter(m=>m.type==='motion'&&m.enabled).length,hiddenPolls,'cached page must stop polling');
+ const returnStart=await v.evaluate(e=>e.scrollLeft);
+ sensor.x=-.1; // Neutral was -.6, reverse is enabled: scroll toward the start.
+ await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));
+ await page.waitForTimeout(600);
+ assert(await v.evaluate(e=>e.scrollLeft)>returnStart+10,'history restore resumes without waiting for the old reply or pointerup');
+ assert(messages.slice(-6).some(m=>m.type==='motion'&&m.enabled&&m.open),'poll carries reader state to repair a native navigation reset');
+ releaseSample({available:false});releaseSample=null;
+ await page.waitForTimeout(250);
+ assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','late pre-navigation reply must not disable the restored reader');
+ // A lost reply without a lifecycle event also recovers through the timeout.
+ holdNextSample=true;
+ await page.waitForTimeout(200);
+ assert(releaseSample);
+ const stalledPolls=messages.filter(m=>m.type==='motion'&&m.enabled).length;
+ await page.waitForTimeout(1250);
+ assert(messages.filter(m=>m.type==='motion'&&m.enabled).length>stalledPolls,'lost native reply must not permanently block polling');
+ releaseSample({available:false});releaseSample=null;
+ await page.waitForTimeout(200);
+ assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','timed-out reply is ignored');
  assert.equal(errors.length,0,errors.join('\n'));
- console.log(JSON.stringify({result:'PASS',engine:await browser.version(),checks:['spacer paragraphs removed','paragraph control saved','tap toggles controls without reflow','native menu bridge','full viewport reading','reading position restored after reload and rotation','theme persisted','original page toggle','accelerometer scrolling','controls brake motion','reverse direction','inactive sensor stops motion','rotation recalibrates','motion preferences persist']}));
+ console.log(JSON.stringify({result:'PASS',engine:await browser.version(),checks:['spacer paragraphs removed','paragraph control saved','tap toggles controls without reflow','native menu bridge','full viewport reading','reading position restored after reload and rotation','theme persisted','original page toggle','accelerometer scrolling','controls brake motion','reverse direction','inactive sensor stops motion','rotation recalibrates','motion preferences persist','cached history restores motion with a pending native reply','late replies ignored','lost reply timeout recovers']}));
  await browser.close();
 })().catch(e=>{console.error(e);process.exit(1)});