commit d9350bf612360f6c11605ae15355df12506f02b0
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-14T04:05:32Z |
| subject | AccelerScroll'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);