penguin/narou-android

Simple 縦書き reader for narou

commit d9350bf612360f6c11605ae15355df12506f02b0

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-14T04:05:32Z
subjectAccelerScroll's Calibation UI follows original workflow
commit d9350bf612360f6c11605ae15355df12506f02b0
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-14T04:05:32Z

    AccelerScroll's Calibation UI follows original workflow
---
 README.md                                | 15 ++++++-
 app/build.gradle                         |  4 +-
 app/src/main/assets/reader/content.js    | 74 +++++++++++++++++++++++++++++---
 app/src/main/assets/reader/manifest.json |  2 +-
 tests/reader.test.cjs                    | 36 +++++++++++++++-
 5 files changed, 120 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md
index 5b81052..57ca324 100644
--- a/README.md
+++ b/README.md
@@ -180,7 +180,20 @@ existing saved position, checkpointed every two seconds while motion scrolling.
 3. Tap **読書に戻る**. Tilt sideways to accelerate, tilt the other way to reverse,
    or return to neutral to brake. Tap the text to stop immediately.
 4. Expand **Tilt scrolling** to adjust acceleration, maximum speed, neutral-zone
-   width, braking strength, or reverse direction. Controls pause scrolling.
+   width, braking strength, or reverse direction. Controls initially pause scrolling.
+
+## Version 1.1.1 — live calibration
+
+Calibration shows a live tilt marker on a −1 to +1 g scale, a shaded braking
+range, its center, and exact trigger thresholds with reading direction labels.
+The stop-range offset slider moves the center; **Set neutral** captures the
+current tilt. The offset remains session-only and resets on rotation, as before.
+The marker keeps updating while paused, and the status shows actual scroll velocity.
+
+Use **Resume preview** to scroll the chapter while calibration stays open;
+**Pause preview** stops immediately. Changes to the sliders take effect during
+preview. Collapsing calibration, toggling controls, disabling tilt or leaving
+the app cancels preview. Ordinary reading resumes when controls are hidden.
 
 Tuning preferences and the enabled switch persist (default OFF).
 Opening a chapter, enabling motion, or rotating captures a fresh neutral angle; calibration
diff --git a/app/build.gradle b/app/build.gradle
index b83936d..b622e2c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,8 +8,8 @@ android {
         applicationId 'net.astropenguin.naroureader'
         minSdk 26
         targetSdk 35
-        versionCode 3
-        versionName '1.1.0'
+        versionCode 4
+        versionName '1.1.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 b5dfaf9..176645a 100644
--- a/app/src/main/assets/reader/content.js
+++ b/app/src/main/assets/reader/content.js
@@ -15,6 +15,7 @@
   const notify = data => browser.runtime.sendNativeMessage('narou', data).catch(() => {});
   function setControls(visible) {
     controlsVisible = visible;
+    calibrationRunning = false;
     panel.classList.toggle('chrome-hidden', !visible);
     header.inert = !visible;
     resetMotion();
@@ -53,6 +54,14 @@
     header { max-height: 70dvh; overflow-y: auto; position: absolute; top: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 4px 18px #0002; border-bottom: 1px solid var(--border); }
     footer { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; background: var(--bg); box-shadow: 0 -4px 18px #0002; border-top: 1px solid var(--border); }
     #motion-settings { flex-basis: 100%; } #motion-settings label { display: flex; flex-wrap: wrap; margin: 5px 0; } #motion-settings summary { cursor: pointer; padding: 8px 0; }
+    #motion-settings input[type=range] { flex: 1; min-width: 100px; }
+    #motion-settings output { min-width: 7ch; font-variant-numeric: tabular-nums; }
+    #motion-gauge { position: relative; height: 48px; margin: 12px 8px 0; border: 1px solid var(--border); border-radius: 6px; background: var(--hover); overflow: hidden; }
+    #motion-band { position: absolute; height: 100%; background: #39866e55; border-left: 2px solid #39866e; border-right: 2px solid #39866e; }
+    #motion-anchor { position: absolute; height: 100%; border-left: 1px dashed var(--fg); }
+    #motion-marker { position: absolute; top: 0; bottom: 0; width: 4px; background: var(--fg); transform: translateX(-50%); }
+    #motion-scale { display: flex; justify-content: space-between; margin: 0 8px; font-variant-numeric: tabular-nums; }
+    #motion-boundaries, #motion-status { margin: 6px 0; font-variant-numeric: tabular-nums; }
     header strong { margin-right: auto; }
     label { display: inline-flex; align-items: center; gap: 6px; }
     input[type=range] { width: 85px; accent-color: #39866e; }
@@ -120,10 +129,27 @@
   header.append(motionToggle);
   const motionDetails = el('details', '', {id: 'motion-settings'});
   motionDetails.append(el('summary', '傾きスクロール設定 / Tilt scrolling'));
-  const motionStatus = el('p', 'Hold the phone comfortably, enable tilt, then set neutral.');
+  const motionStatus = el('p', 'Hold the phone comfortably, enable tilt, then set neutral.', {id: 'motion-status'});
+  const motionGauge = el('div', '', {id: 'motion-gauge', role: 'img', 'aria-label': 'Live tilt and stop range'});
+  const motionBand = el('span', '', {id: 'motion-band'});
+  const motionAnchor = el('span', '', {id: 'motion-anchor'});
+  const motionMarker = el('span', '', {id: 'motion-marker'});
+  motionMarker.hidden = true;
+  motionGauge.append(motionBand, motionAnchor, motionMarker);
+  const motionScale = el('div', '', {id: 'motion-scale'});
+  for (const text of ['−1 g', '0', '+1 g']) motionScale.append(el('span', text));
+  const motionBoundaries = el('p', '', {id: 'motion-boundaries'});
+  const previewButton = el('button', '再開 / Resume preview', {id: 'motion-preview', type: 'button', 'aria-pressed': 'false'});
+  previewButton.disabled = true;
+  let calibrationRunning = false;
   const neutralButton = el('button', 'この傾きを基準に / Set neutral', {type: 'button', id: 'motion-neutral'});
   neutralButton.disabled = true;
-  motionDetails.append(motionStatus, neutralButton);
+  motionDetails.append(motionGauge, motionScale, motionBoundaries, motionStatus, neutralButton, previewButton);
+  const offsetLabel = el('label', '停止範囲の中心 / Stop range offset');
+  const offsetInput = el('input', '', {id: 'motion-offset', type: 'range', min: -1, max: 1, step: .01, value: 0});
+  const offsetOutput = el('output', '0.00 g');
+  offsetLabel.append(offsetInput, offsetOutput); motionDetails.append(offsetLabel);
+  offsetInput.addEventListener('input', () => { neutral = Number(offsetInput.value); resetMotion(); updateCalibration(); });
   const motionInputs = {};
   for (const [key, label, min, max, step, suffix] of [
     ['acceleration', '加速 / Acceleration', 100, 3000, 100, ' px/s²'],
@@ -138,6 +164,7 @@
     input.addEventListener('input', () => {
       motionSettings[key] = Number(input.value); output.textContent = input.value + suffix;
       resetMotion(); saveMotion();
+      updateCalibration();
     });
     motionInputs[key] = {input, output, suffix, min, max};
     labelNode.append(input, output); motionDetails.append(labelNode);
@@ -146,7 +173,7 @@
   const reverseMotion = el('input', '', {id: 'motion-reverse', type: 'checkbox'});
   reverseLabel.prepend(reverseMotion); motionDetails.append(reverseLabel);
   reverseMotion.addEventListener('change', () => { motionSettings.reverse = reverseMotion.checked; resetMotion(); saveMotion(); });
-  motionDetails.append(el('p', 'Tilt sideways to accelerate. Return to neutral to brake. Touch the text to stop. Settings stay paused; return to reading to scroll.'));
+  motionDetails.append(el('p', 'Shaded range: braking. Outside the boundaries: accelerate. Resume preview to scroll the text while tuning; Pause stops immediately. The live marker keeps updating while paused.'));
   header.append(motionDetails);
   const viewport = el('div', '', {id: 'viewport', tabindex: '0', role: 'region', 'aria-label': '本文。左矢印またはスペースで読み進めます。'});
   const article = el('article', '', {lang: 'ja'});
@@ -215,6 +242,29 @@
   let neutral = null, motionRotation = null, touching = false, motionTimer, motionFrame;
   let motionGeneration = 0, polling = false;
   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;
+    const percent = value => `${Math.min(100, Math.max(0, (value + 1) * 50))}%`;
+    const center = neutral ?? 0, low = center - motionSettings.deadzone, high = center + motionSettings.deadzone;
+    motionBand.style.left = percent(low);
+    motionBand.style.width = `${(Math.min(1, high) - Math.max(-1, low)) * 50}%`;
+    motionAnchor.style.left = percent(center);
+    motionMarker.hidden = !ready;
+    if (ready) motionMarker.style.left = percent(motionSample.x);
+    offsetInput.value = center; offsetOutput.textContent = `${center.toFixed(2)} g`;
+    const left = motionSettings.reverse ? '← Forward' : '→ Back';
+    const right = motionSettings.reverse ? '→ Back' : '← Forward';
+    motionBoundaries.textContent = `${left}: < ${low.toFixed(2)} g · Brake: ${low.toFixed(2)} to ${high.toFixed(2)} g · ${right}: > ${high.toFixed(2)} g`;
+    previewButton.disabled = !ready;
+    previewButton.textContent = calibrationRunning ? '一時停止 / Pause preview' : '再開 / Resume preview';
+    previewButton.setAttribute('aria-pressed', String(calibrationRunning));
+    if (!ready) return;
+    const braking = Math.abs(motionSample.x - neutral) <= motionSettings.deadzone;
+    const forward = (motionSample.x - neutral) * (motionSettings.reverse ? -1 : 1) > 0;
+    const state = controlsVisible && !calibrationRunning ? 'Paused' : touching ? 'Paused — touch' : braking ? 'Braking' : forward ? '← Forward acceleration' : '→ Back acceleration';
+    motionStatus.textContent = `Tilt ${motionSample.x.toFixed(2)} g · ${state} · Velocity ${motion.velocity.toFixed(1)} px/s`;
+    motionGauge.setAttribute('aria-label', `Tilt ${motionSample.x.toFixed(2)} g; brake range ${low.toFixed(2)} to ${high.toFixed(2)} g; ${state}`);
+  }
   function saveMotion() { browser.storage.local.set({narouMotion: {...motionSettings, enabled: motionEnabled}}).catch(() => {}); }
   function updateMotionButton() {
     motionToggle.textContent = motionEnabled ? '傾き: ON' : '傾き: OFF';
@@ -223,7 +273,7 @@
   function animateMotion(now) {
     motionFrame = null;
     if (!motionEnabled || !opened || document.hidden) { resetMotion(); return; }
-    const paused = controlsVisible || touching || restoring || !motionSample?.active ||
+    const paused = (controlsVisible && !(motionDetails.open && calibrationRunning)) || touching || restoring || !motionSample?.active ||
       now - sampleAt > 350 || neutral === null || String(window.getSelection()).trim();
     if (paused) resetMotion();
     else {
@@ -234,6 +284,7 @@
       }
       lastFrame = now;
     }
+    updateCalibration(now);
     motionFrame = requestAnimationFrame(animateMotion);
   }
   async function pollMotion() {
@@ -258,7 +309,7 @@
         if (motionRotation !== sample.rotation) {
           motionRotation = sample.rotation; neutral = sample.x; resetMotion();
         }
-        motionStatus.textContent = `Tilt ${sample.x.toFixed(2)} g · Neutral ${neutral.toFixed(2)} g${controlsVisible ? ' · Paused' : ''}`;
+        updateCalibration();
       }
 	} catch (error) {
 		if (generation !== motionGeneration) return;
@@ -283,8 +334,10 @@
     clearTimeout(motionTimer); pollMotion();
   }
   function stopMotion() {
+    calibrationRunning = false;
     motionGeneration++; clearTimeout(motionTimer); cancelAnimationFrame(motionFrame); motionFrame = null;
     motionSample = null; neutralButton.disabled = true; resetMotion();
+    updateCalibration();
     notify({type: 'motion', enabled: false});
   }
   motionToggle.addEventListener('click', () => {
@@ -295,7 +348,15 @@
   neutralButton.addEventListener('click', () => {
     if (!motionSample?.active || performance.now() - sampleAt > 350) return;
     neutral = motionSample.x; resetMotion();
-    motionStatus.textContent = `Neutral set: ${neutral.toFixed(2)} g. Return to reading to start.`;
+    updateCalibration();
+  });
+  previewButton.addEventListener('click', () => {
+    if (previewButton.disabled || !motionDetails.open || !controlsVisible) return;
+    calibrationRunning = !calibrationRunning; resetMotion(); updateCalibration();
+  });
+  motionDetails.addEventListener('toggle', () => {
+    if (!motionDetails.open) { calibrationRunning = false; resetMotion(); }
+    updateCalibration();
   });
   viewport.addEventListener('pointerdown', () => { touching = true; resetMotion(); }, {passive: true});
   window.addEventListener('pointerup', () => { touching = false; }, {passive: true});
@@ -319,6 +380,7 @@
     }
     motionSettings.reverse = stored.reverse === true; reverseMotion.checked = motionSettings.reverse;
     motionEnabled = stored.enabled === true; updateMotionButton(); startMotion();
+    updateCalibration();
   }).catch(() => {});
 
   const range = () => Math.max(0, viewport.scrollWidth - viewport.clientWidth);
diff --git a/app/src/main/assets/reader/manifest.json b/app/src/main/assets/reader/manifest.json
index 8305f77..493c957 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": "縦書き Reader — Android",
-  "version": "1.1.0",
+  "version": "1.1.1",
   "description": "Built-in vertical reading view for Narou Reader.",
   "browser_specific_settings": {
     "gecko": {
diff --git a/tests/reader.test.cjs b/tests/reader.test.cjs
index e3648fb..72e58aa 100644
--- a/tests/reader.test.cjs
+++ b/tests/reader.test.cjs
@@ -68,6 +68,40 @@ const ROOT=path.resolve(__dirname,'..');
  const stopped=await v.evaluate(e=>e.scrollLeft);
  await page.waitForTimeout(350);
  assert.equal(await v.evaluate(e=>e.scrollLeft),stopped,'controls must pause motion');
+ // Calibration uses the same live sensor and physics as reading, even with controls open.
+ const marker=panel.locator('#motion-marker'), band=panel.locator('#motion-band');
+ const preview=panel.locator('#motion-preview');
+ assert(await marker.isVisible(),'live tilt marker is visible while paused');
+ assert.equal(await marker.evaluate(e=>e.style.left),'75%');
+ assert.equal(await band.evaluate(e=>e.style.left),'46%');
+ assert.match(await panel.locator('#motion-boundaries').textContent(),/-0.08.*0.08/);
+ await preview.click();
+ await page.waitForTimeout(500);
+ assert(await v.evaluate(e=>e.scrollLeft)<stopped-10,'preview scrolls actual text with calibration open');
+ await preview.click();
+ const previewStopped=await v.evaluate(e=>e.scrollLeft);
+ sensor.x=-.4;
+ await page.waitForTimeout(250);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),previewStopped,'pause immediately stops preview');
+ assert.equal(await marker.evaluate(e=>e.style.left),'30%','sensor marker updates while paused');
+ await panel.locator('#motion-offset').evaluate(e=>{e.value='-0.4';e.dispatchEvent(new Event('input'));});
+ await panel.locator('#motion-deadzone').evaluate(e=>{e.value='0.12';e.dispatchEvent(new Event('input'));});
+ assert.match(await panel.locator('#motion-boundaries').textContent(),/-0.52.*-0.28/);
+ await preview.click();await page.waitForTimeout(250);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),previewStopped,'shaded neutral range matches actual physics');
+ sensor.x=.5;await page.waitForTimeout(300);
+ await panel.locator('#motion-settings summary').click();
+ const collapsed=await v.evaluate(e=>e.scrollLeft);
+ await page.waitForTimeout(250);
+ assert.equal(await v.evaluate(e=>e.scrollLeft),collapsed,'collapsing calibration stops preview');
+ await panel.locator('#motion-settings summary').click();
+ assert.equal(await preview.getAttribute('aria-pressed'),'false');
+ await panel.locator('#motion-deadzone').evaluate(e=>{e.value='0.08';e.dispatchEvent(new Event('input'));});
+ if(process.env.CALIBRATION_SCREENSHOT){
+   await page.setViewportSize({width:412,height:840});await page.waitForTimeout(200);
+   await page.screenshot({path:process.env.CALIBRATION_SCREENSHOT});
+   await page.setViewportSize({width:840,height:412});await page.waitForTimeout(200);
+ }
  sensor.x=0;
  await page.waitForTimeout(200);
  await panel.locator('#motion-neutral').click();
@@ -75,7 +109,7 @@ const ROOT=path.resolve(__dirname,'..');
  await panel.getByRole('button',{name:'読書に戻る'}).click();
  sensor.x=.5;
  await page.waitForTimeout(800);
- assert(await v.evaluate(e=>e.scrollLeft)>stopped+15,'reverse must change direction');
+ assert(await v.evaluate(e=>e.scrollLeft)>collapsed+15,'reverse must change direction');
  sensor.active=false;
  await page.waitForTimeout(250);
  const inactive=await v.evaluate(e=>e.scrollLeft);