commit 9db34e28218a232763d4bb55db8dcc7b7988308c
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-14T05:02:26Z |
| subject | Added langpacks |
commit 9db34e28218a232763d4bb55db8dcc7b7988308c
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-14T05:02:26Z
Added langpacks
---
CHANGELOG.md | 92 ++++++++++++++
README.md | 98 +--------------
app/build.gradle | 4 +-
app/src/main/assets/reader/content.js | 135 +++++++++++++--------
app/src/main/assets/reader/i18n.js | 39 ++++++
app/src/main/assets/reader/langpacks/config.json | 7 ++
app/src/main/assets/reader/langpacks/en.json | 89 ++++++++++++++
app/src/main/assets/reader/langpacks/ja.json | 89 ++++++++++++++
app/src/main/assets/reader/manifest.json | 8 +-
.../astropenguin/naroureader/LanguagePacks.java | 61 ++++++++++
.../net/astropenguin/naroureader/MainActivity.java | 64 +++++-----
tests/i18n.test.cjs | 43 +++++++
tests/reader.test.cjs | 33 +++--
13 files changed, 573 insertions(+), 189 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..503c66f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,92 @@
+## Version 1.3.0 — language packs
+
+Reader controls and native Android dialogs now share bundled JSON strings in
+`app/src/main/assets/reader/langpacks/en.json` and `ja.json`. Labels use one
+language at a time. Open the reader controls and choose **Language** to switch
+immediately; the choice is saved and also applies to native menus and prompts.
+The chapter text remains Japanese.
+
+`langpacks/config.json` sets `defaultLanguage` and the `supportedLanguages` map
+(language code → display name). The default is English. To add a language, copy
+`en.json` to `<code>.json`, translate its values, and register its code and name
+in the map. Language codes use letters with optional hyphen-separated subtags.
+Keep keys and `{placeholder}` names intact. Translation strings are plain text.
+Packs load from bundled extension assets, without remote requests. Missing keys,
+unsupported saved selections and failed selected-pack loads use the configured
+default pack. The config and default pack must be valid and bundled.
+
+The native app reads the same config and restores its last language at startup;
+reader language changes reach it through the existing native message bridge.
+Already-open native dialogs use their language at creation time.
+
+Validation (no APK build): `node tests/i18n.test.cjs`,
+`node tests/motion.test.cjs`, and `node tests/reader.test.cjs`. Reader tests cover
+live language changes, persistence, preserved colors/position and native messages.
+The extension version is bumped to ensure GeckoView reloads its bundled assets.
+
+## Version 1.2.0 — paragraph focus
+
+Tap any paragraph, including its ruby text, to apply the focused text color.
+Only one paragraph is focused at a time. Enter or Space on a keyboard-focused
+paragraph also selects it. Links, swipes and long-press text selection keep
+their existing behavior. Tap the focused paragraph again or tap empty space
+to toggle controls.
+
+While accelerometer scrolling moves the text outside the braking range, focus
+tracks the visible paragraph nearest the screen center, in either direction
+and during calibration preview. Inside the braking range, the current focus
+stays unchanged, even while residual velocity brakes. Manual scrolling does
+not automatically change focus.
+
+**Normal text** and **Focus text** color controls save your choices locally.
+Custom colors apply across themes; **Reset text colors** restores the current
+theme's defaults. Focus changes only text color and does not shift the layout.
+
+## 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
+is intentionally session-only so an old grip does not unexpectedly start scrolling.
+Touch/selection pauses motion. Closing the reader, losing app focus, backgrounding,
+missing sensor samples or navigation stops it. The Android listener has a one-second
+watchdog and unregisters when unused. Screen-on is requested only while moving.
+No new Android permission is required. Devices without an accelerometer show an
+unavailable message; ordinary swipe reading still works.
+
+Implementation: `TiltSensor.java` collects filtered, display-relative samples;
+`motion.js` holds the acceleration/braking model; `content.js` owns calibration,
+controls, animation, and native polling. Android sensor coordinates follow the
+[Android sensor documentation](https://developer.android.com/develop/sensors-and-location/sensors/sensors_overview).
+
+Run `node tests/motion.test.cjs` for physics checks and `node tests/reader.test.cjs`
+for Firefox integration checks with a simulated native accelerometer. The latter
+covers movement, pausing, reversing, inactive samples, rotation and settings.
+Actual sensor feel and device lifecycle still require a phone check.
+
+## Version 1.1.0 — accelerometer scrolling
+
+Ported from your Windows AccelerScroll.cs and ReaderContent.xaml.cs. This uses
+sideways accelerometer tilt (including gravity), not gyroscope angular velocity.
+The acceleration → capped velocity → neutral-zone braking behavior is retained.
+Values now use CSS pixels/second and pixels/second², rather than pixels/timer tick.
+The neutral zone is symmetric around the calibrated position, correcting the old
+offset-dependent range. The old auto-anchor option is replaced by the reader's
+existing saved position, checkpointed every two seconds while motion scrolling.
+
+1. Tap empty space (or the focused paragraph) to show controls. Tap **傾き: OFF** to enable it.
+2. Hold the phone at your comfortable reading angle; tap **Set neutral**.
+3. Tap **読書に戻る**. Tilt sideways to accelerate, tilt the other way to reverse,
+ or return to neutral to brake. Opening controls stops scrolling immediately.
+4. Expand **Tilt scrolling** to adjust acceleration, maximum speed, neutral-zone
+ width, braking strength, or reverse direction. Controls initially pause scrolling.
diff --git a/README.md b/README.md
index 69e5788..ed5fb5b 100644
--- a/README.md
+++ b/README.md
@@ -137,98 +137,6 @@ can be browsed normally; clicked external HTTPS links open in another browser.
App code is MIT-licensed. GeckoView is MPL 2.0 with third-party notices; see
`THIRD_PARTY_NOTICES.md` and app menu → About / help → Engine licenses.
-## JDK 27 build update
-
-Gradle 9.6 cannot run on JDK 27 and fails with `Unsupported class file major
-version 71`. This project now pins Gradle 9.8.1, including its distribution
-SHA-256 and regenerated wrapper files. No JDK downgrade is required.
-
-For an existing checkout, copy the updated `gradle/wrapper/` directory, `gradlew`,
-`gradlew.bat`, and `build.sh` from this archive. Preserve your application code
-and signing key. If you maintain your own script, ensure it exits after a failed
-Gradle command instead of continuing to adb.
-
-Verified with a clean ARM64/x86_64 build and Android lint using Amazon Corretto
-OpenJDK 27+35-FR and Gradle 9.8.1 on Linux. App class files remain Java 17
-(major version 61). Build-script checks cover compilation failure, build-only
-mode, missing devices, and ABI selection for installation.
-
-## Version 1.0.1 — camera cutout theme
-
-The native Android window and camera-cutout safe area now follow the selected
-paper, white or dark reader theme. The native theme is restored at startup, and
-system-bar icon contrast follows the theme when the bars are temporarily shown.
-The bundled extension version is also bumped so installed apps load the update.
-
-Install this APK over the existing app to keep settings and reading positions.
-It uses the same development signing key. When updating an existing source build,
-run `./gradlew :app:clean` once before `./build.sh --install` to remove stale APK
-packaging state. The updated reader regression verifies theme messages on first
-load, theme changes and saved-theme restoration.
-
-## Version 1.1.0 — accelerometer scrolling
-
-Ported from your Windows AccelerScroll.cs and ReaderContent.xaml.cs. This uses
-sideways accelerometer tilt (including gravity), not gyroscope angular velocity.
-The acceleration → capped velocity → neutral-zone braking behavior is retained.
-Values now use CSS pixels/second and pixels/second², rather than pixels/timer tick.
-The neutral zone is symmetric around the calibrated position, correcting the old
-offset-dependent range. The old auto-anchor option is replaced by the reader's
-existing saved position, checkpointed every two seconds while motion scrolling.
-
-1. Tap empty space (or the focused paragraph) to show controls. Tap **傾き: OFF** to enable it.
-2. Hold the phone at your comfortable reading angle; tap **Set neutral**.
-3. Tap **読書に戻る**. Tilt sideways to accelerate, tilt the other way to reverse,
- or return to neutral to brake. Opening controls stops scrolling immediately.
-4. Expand **Tilt scrolling** to adjust acceleration, maximum speed, neutral-zone
- 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
-is intentionally session-only so an old grip does not unexpectedly start scrolling.
-Touch/selection pauses motion. Closing the reader, losing app focus, backgrounding,
-missing sensor samples or navigation stops it. The Android listener has a one-second
-watchdog and unregisters when unused. Screen-on is requested only while moving.
-No new Android permission is required. Devices without an accelerometer show an
-unavailable message; ordinary swipe reading still works.
-
-Implementation: `TiltSensor.java` collects filtered, display-relative samples;
-`motion.js` holds the acceleration/braking model; `content.js` owns calibration,
-controls, animation, and native polling. Android sensor coordinates follow the
-[Android sensor documentation](https://developer.android.com/develop/sensors-and-location/sensors/sensors_overview).
-
-Run `node tests/motion.test.cjs` for physics checks and `node tests/reader.test.cjs`
-for Firefox integration checks with a simulated native accelerometer. The latter
-covers movement, pausing, reversing, inactive samples, rotation and settings.
-Actual sensor feel and device lifecycle still require a phone check.
-
-## Version 1.2.0 — paragraph focus
-
-Tap any paragraph, including its ruby text, to apply the focused text color.
-Only one paragraph is focused at a time. Enter or Space on a keyboard-focused
-paragraph also selects it. Links, swipes and long-press text selection keep
-their existing behavior. Tap the focused paragraph again or tap empty space
-to toggle controls.
-
-While accelerometer scrolling moves the text outside the braking range, focus
-tracks the visible paragraph nearest the screen center, in either direction
-and during calibration preview. Inside the braking range, the current focus
-stays unchanged, even while residual velocity brakes. Manual scrolling does
-not automatically change focus.
-
-**Normal text** and **Focus text** color controls save your choices locally.
-Custom colors apply across themes; **Reset text colors** restores the current
-theme's defaults. Focus changes only text color and does not shift the layout.
+## Changelogs
+
+See [CHANGELOG](CHANGELOG.md)
diff --git a/app/build.gradle b/app/build.gradle
index 895dd6b..20796c3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -8,8 +8,8 @@ android {
applicationId 'net.astropenguin.naroureader'
minSdk 26
targetSdk 35
- versionCode 5
- versionName '1.2.0'
+ versionCode 6
+ versionName '1.3.0'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
diff --git a/app/src/main/assets/reader/content.js b/app/src/main/assets/reader/content.js
index 4049284..00f97a9 100644
--- a/app/src/main/assets/reader/content.js
+++ b/app/src/main/assets/reader/content.js
@@ -1,13 +1,16 @@
-(() => {
+(async () => {
'use strict';
if (document.getElementById('narou-tategaki-host')) return;
const sources = [...document.querySelectorAll('.p-novel__text')];
if (!sources.length) sources.push(...document.querySelectorAll('#novel_p, #novel_honbun, #novel_a'));
if (!sources.length) return; // Leave table-of-contents and other pages alone.
+ const i18n = await NarouI18n.create();
+ const t = (key, values) => i18n.text(key, values);
+ const bindings = [];
const motionDefaults = {acceleration: 900, speed: 120, deadzone: .08, brake: .15, reverse: false};
let motionSettings = {...motionDefaults};
- const defaults = {font: 20, spacing: 1.9, paragraph: 0.3, theme: 'paper', auto: true, normalColor: null, highlightColor: null};
+ const defaults = {font: 20, spacing: 1.9, paragraph: 0.3, theme: 'paper', auto: true, normalColor: null, highlightColor: null, language: i18n.language};
const textColors = {paper: ['#2d332f', '#17694f'], white: ['#222222', '#17694f'], dark: ['#e2e4de', '#79dfb5']};
let settings = {...defaults}, opened = false, lastFocus;
let nativeTheme = null;
@@ -95,15 +98,22 @@
shadow.append(style);
const el = (tag, text, attrs = {}) => {
const node = document.createElement(tag);
- if (text) node.textContent = text;
- for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
+ if (text) {
+ const textNode = document.createTextNode(text.startsWith('@') ? t(text.slice(1)) : text);
+ node.append(textNode);
+ if (text.startsWith('@')) bindings.push(() => { textNode.data = t(text.slice(1)); });
+ }
+ for (const [key, value] of Object.entries(attrs)) {
+ node.setAttribute(key, value.startsWith?.('@') ? t(value.slice(1)) : value);
+ if (value.startsWith?.('@')) bindings.push(() => node.setAttribute(key, t(value.slice(1))));
+ }
return node;
};
- const launch = el('button', '縦書き', {id: 'launch', type: 'button', title: '縦書き Reader (Alt+Shift+V)'});
- const panel = el('div', '', {id: 'panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '縦書き Reader'});
+ const launch = el('button', '@reader.launch', {id: 'launch', type: 'button', title: '@reader.shortcut'});
+ const panel = el('div', '', {id: 'panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '@reader.name'});
panel.hidden = true;
const header = el('header');
- header.append(el('strong', '縦書き Reader'));
+ header.append(el('strong', '@reader.name'));
function slider(label, id, min, max, step) {
const wrap = el('label', label);
const input = el('input', '', {id, type: 'range', min, max, step});
@@ -111,39 +121,56 @@
wrap.append(input, output); header.append(wrap);
return [input, output];
}
- const [font, fontOut] = slider('文字', 'font', 14, 40, 1);
- const [spacing, spaceOut] = slider('行間', 'spacing', 1.4, 3, 0.1);
- const [paragraph, paragraphOut] = slider('段落', 'paragraph', 0, 2, 0.1);
- const themeLabel = el('label', '配色');
- const theme = el('select', '', {'aria-label': '配色'});
- for (const [value, label] of [['paper', '紙'], ['white', '白'], ['dark', '黒']]) theme.append(el('option', label, {value}));
+ const [font, fontOut] = slider('@font', 'font', 14, 40, 1);
+ const [spacing, spaceOut] = slider('@spacing', 'spacing', 1.4, 3, 0.1);
+ const [paragraph, paragraphOut] = slider('@paragraph', 'paragraph', 0, 2, 0.1);
+ const themeLabel = el('label', '@theme');
+ const theme = el('select', '', {'aria-label': '@theme'});
+ for (const [value, label] of [['paper', '@paper'], ['white', '@white'], ['dark', '@dark']]) theme.append(el('option', label, {value}));
themeLabel.append(theme); header.append(themeLabel);
- const autoLabel = el('label', '自動');
- const auto = el('input', '', {type: 'checkbox', title: 'Automatically open vertical mode on chapters'});
+ const languageLabel = el('label', '@language');
+ const languageSelect = el('select', '', {id: 'reading-language', 'aria-label': '@language'});
+ for (const [code, name] of Object.entries(i18n.config.supportedLanguages)) languageSelect.append(el('option', name, {value: code}));
+ languageLabel.append(languageSelect); header.append(languageLabel);
+ let statusKey = 'tilt.hint', statusValues = {};
+ function setStatus(key, values = {}) { statusKey = key; statusValues = values; motionStatus.textContent = t(key, values); }
+ languageSelect.addEventListener('change', async () => {
+ languageSelect.disabled = true;
+ try {
+ await i18n.setLanguage(languageSelect.value);
+ settings.language = i18n.language;
+ bindings.forEach(update => update());
+ panel.lang = i18n.language;
+ setStatus(statusKey, statusValues); updateMotionButton(); updateCalibration();
+ notify({type: 'language', language: settings.language}); save();
+ } finally { languageSelect.value = i18n.language; languageSelect.disabled = false; }
+ });
+ const autoLabel = el('label', '@auto');
+ const auto = el('input', '', {type: 'checkbox', title: '@auto.hint'});
autoLabel.prepend(auto); header.append(autoLabel);
const colorInputs = {};
- for (const [key, label] of [['normalColor', '通常の文字 / Normal text'], ['highlightColor', '注目の文字 / Focus text']]) {
+ for (const [key, label] of [['normalColor', '@normalColor'], ['highlightColor', '@highlightColor']]) {
const wrap = el('label', label);
const input = el('input', '', {type: 'color', id: `reading-${key}`});
colorInputs[key] = input; wrap.append(input); header.append(wrap);
input.addEventListener('input', () => { settings[key] = input.value; applyTextColors(); save(); });
}
- const resetColors = el('button', '配色の文字色に戻す / Reset text colors', {type: 'button', id: 'reading-reset-colors'});
+ const resetColors = el('button', '@resetColors', {type: 'button', id: 'reading-reset-colors'});
resetColors.addEventListener('click', () => { settings.normalColor = settings.highlightColor = null; applyTextColors(); save(); });
header.append(resetColors);
- const appMenu = el('button', '☰', {type: 'button', 'aria-label': 'App menu'});
+ const appMenu = el('button', '☰', {type: 'button', 'aria-label': '@appMenu'});
appMenu.addEventListener('click', () => notify({type: 'menu'}));
header.prepend(appMenu);
- const close = el('button', '元のページ', {type: 'button'});
- const hideControls = el('button', '読書に戻る', {type: 'button'});
+ const close = el('button', '@original', {type: 'button'});
+ const hideControls = el('button', '@read', {type: 'button'});
hideControls.addEventListener('click', () => { setControls(false); viewport.focus({preventScroll: true}); });
header.append(close, hideControls);
- const motionToggle = el('button', '傾き: OFF', {id: 'motion-toggle', type: 'button', 'aria-pressed': 'false'});
+ const motionToggle = el('button', '@tilt.off', {id: 'motion-toggle', type: 'button', 'aria-pressed': 'false'});
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.', {id: 'motion-status'});
- const motionGauge = el('div', '', {id: 'motion-gauge', role: 'img', 'aria-label': 'Live tilt and stop range'});
+ motionDetails.append(el('summary', '@tilt.settings'));
+ const motionStatus = el('p', '@tilt.hint', {id: 'motion-status'});
+ const motionGauge = el('div', '', {id: 'motion-gauge', role: 'img', 'aria-label': '@tilt.gauge'});
const motionBand = el('span', '', {id: 'motion-band'});
const motionAnchor = el('span', '', {id: 'motion-anchor'});
const motionMarker = el('span', '', {id: 'motion-marker'});
@@ -152,23 +179,23 @@
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'});
+ const previewButton = el('button', '@tilt.resume', {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'});
+ const neutralButton = el('button', '@tilt.neutral', {type: 'button', id: 'motion-neutral'});
neutralButton.disabled = true;
motionDetails.append(motionGauge, motionScale, motionBoundaries, motionStatus, neutralButton, previewButton);
- const offsetLabel = el('label', '停止範囲の中心 / Stop range offset');
+ const offsetLabel = el('label', '@tilt.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²'],
- ['speed', '最高速度 / Max speed', 20, 600, 10, ' px/s'],
- ['deadzone', '停止範囲 / Neutral zone ±', .02, .4, .01, ' g'],
- ['brake', 'ブレーキ / Braking', .01, 1, .01, '']
+ ['acceleration', '@tilt.acceleration', 100, 3000, 100, ' px/s²'],
+ ['speed', '@tilt.speed', 20, 600, 10, ' px/s'],
+ ['deadzone', '@tilt.deadzone', .02, .4, .01, ' g'],
+ ['brake', '@tilt.brake', .01, 1, .01, '']
]) {
const labelNode = el('label', label);
const input = el('input', '', {id: `motion-${key}`, type: 'range', min, max, step});
@@ -182,13 +209,13 @@
motionInputs[key] = {input, output, suffix, min, max};
labelNode.append(input, output); motionDetails.append(labelNode);
}
- const reverseLabel = el('label', '方向を逆転 / Reverse direction');
+ const reverseLabel = el('label', '@tilt.reverse');
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', '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.'));
+ motionDetails.append(el('p', '@tilt.help'));
header.append(motionDetails);
- const viewport = el('div', '', {id: 'viewport', tabindex: '0', role: 'region', 'aria-label': '本文。左矢印またはスペースで読み進めます。'});
+ const viewport = el('div', '', {id: 'viewport', tabindex: '0', role: 'region', 'aria-label': '@viewport'});
const article = el('article', '', {lang: 'ja'});
const title = document.querySelector('.p-novel__title, .novel_subtitle');
article.append(el('h1', title ? title.textContent.trim() : document.title));
@@ -224,8 +251,8 @@
}
for (const source of sources) {
const section = el('section');
- if (source.matches('.p-novel__text--preface, #novel_p')) section.append(el('h2', '前書き'));
- if (source.matches('.p-novel__text--afterword, #novel_a')) section.append(el('h2', '後書き'));
+ if (source.matches('.p-novel__text--preface, #novel_p')) section.append(el('h2', '@preface'));
+ if (source.matches('.p-novel__text--afterword, #novel_a')) section.append(el('h2', '@afterword'));
for (const child of source.childNodes) section.append(copy(child));
article.append(section);
}
@@ -259,19 +286,19 @@
}
});
const footer = el('footer');
- const nav = el('nav', '', {'aria-label': '章移動'});
+ const nav = el('nav', '', {'aria-label': '@nav'});
const pager = document.querySelector('.c-pager, .novel_bn');
if (pager) for (const link of pager.querySelectorAll('a[href]')) {
const url = safeURL(link.getAttribute('href'));
if (url) nav.append(el('a', link.textContent.trim(), {href: url}));
}
footer.append(nav);
- const back = el('button', '→', {type: 'button', 'aria-label': '前の列'});
- const forward = el('button', '←', {type: 'button', 'aria-label': '次の列'});
+ const back = el('button', '→', {type: 'button', 'aria-label': '@back'});
+ const forward = el('button', '←', {type: 'button', 'aria-label': '@forward'});
footer.append(back, forward);
- const progress = el('input', '', {id: 'progress', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': '読書位置'});
+ const progress = el('input', '', {id: 'progress', type: 'range', min: '0', max: '1000', value: '0', 'aria-label': '@progress'});
const percent = el('span', '0%', {id: 'percent'});
- footer.append(progress, percent, el('span', 'タップで操作を表示 · 右へスワイプで読み進める', {class: 'hint'}));
+ footer.append(progress, percent, el('span', '@hint', {class: 'hint'}));
panel.append(header, viewport, footer); shadow.append(launch, panel);
document.body.append(host);
const lock = el('style');
@@ -293,22 +320,22 @@
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`;
+ const left = motionSettings.reverse ? t('tilt.forward') : t('tilt.back');
+ const right = motionSettings.reverse ? t('tilt.back') : t('tilt.forward');
+ motionBoundaries.textContent = t('tilt.boundaries', {left, right, low: low.toFixed(2), high: high.toFixed(2)});
previewButton.disabled = !ready;
- previewButton.textContent = calibrationRunning ? '一時停止 / Pause preview' : '再開 / Resume preview';
+ previewButton.textContent = t(calibrationRunning ? 'tilt.pause' : 'tilt.resume');
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}`);
+ const state = controlsVisible && !calibrationRunning ? t('paused') : touching ? t('touch') : braking ? t('braking') : forward ? t('accelerate.forward') : t('accelerate.back');
+ setStatus('tilt.status', {x: motionSample.x.toFixed(2), state, velocity: motion.velocity.toFixed(1)});
+ motionGauge.setAttribute('aria-label', t('tilt.range', {x: motionSample.x.toFixed(2), low: low.toFixed(2), high: high.toFixed(2), state}));
}
function saveMotion() { browser.storage.local.set({narouMotion: {...motionSettings, enabled: motionEnabled}}).catch(() => {}); }
function updateMotionButton() {
- motionToggle.textContent = motionEnabled ? '傾き: ON' : '傾き: OFF';
+ motionToggle.textContent = t(motionEnabled ? 'tilt.on' : 'tilt.off');
motionToggle.setAttribute('aria-pressed', String(motionEnabled));
}
function animateMotion(now) {
@@ -341,13 +368,13 @@
if (generation !== motionGeneration) return;
if (!sample?.available) {
motionEnabled = false; updateMotionButton();
- motionStatus.textContent = 'Accelerometer unavailable on this device.';
+ setStatus('sensor.missing');
stopMotion(); return;
}
motionSample = sample; sampleAt = performance.now();
neutralButton.disabled = !sample.active;
if (!sample.active || !Number.isFinite(sample.x)) {
- motionSample = null; resetMotion(); motionStatus.textContent = 'Paused — waiting for sensor.';
+ motionSample = null; resetMotion(); setStatus('sensor.wait');
} else {
if (motionRotation !== sample.rotation) {
motionRotation = sample.rotation; neutral = sample.x; resetMotion();
@@ -365,7 +392,7 @@
error?.message || error || 'Unknown error'
).slice(0, 240);
- motionStatus.textContent = `Sensor connection unavailable: ${detail}`;
+ setStatus('sensor.error', {detail});
} finally {
polling = false;
if (motionEnabled && opened && !document.hidden) motionTimer = setTimeout(pollMotion, 100);
@@ -386,7 +413,7 @@
motionToggle.addEventListener('click', () => {
motionEnabled = !motionEnabled; updateMotionButton(); saveMotion();
if (motionEnabled) { neutral = null; motionRotation = null; motionDetails.open = true; startMotion(); }
- else { stopMotion(); motionStatus.textContent = 'Tilt scrolling off.'; }
+ else { stopMotion(); setStatus('sensor.off'); }
});
neutralButton.addEventListener('click', () => {
if (!motionSample?.active || performance.now() - sampleAt > 350) return;
@@ -446,6 +473,7 @@
font.value = settings.font; spacing.value = settings.spacing; paragraph.value = settings.paragraph;
fontOut.textContent = `${settings.font}px`; spaceOut.textContent = `${settings.spacing.toFixed(1)}`;
paragraphOut.textContent = `${settings.paragraph.toFixed(1)}em`;
+ languageSelect.value = i18n.language; panel.lang = i18n.language;
theme.value = settings.theme; auto.checked = settings.auto;
requestAnimationFrame(() => {
if (opened) viewport.scrollLeft = -position * range();
@@ -557,6 +585,7 @@
for (const key of ['normalColor', 'highlightColor']) settings[key] = /^#[0-9a-f]{6}$/i.test(stored[key]) ? stored[key] : null;
if (settings.auto && !opened) open(); else apply(savedPosition);
}).catch(() => { open(); });
+ notify({type: 'language', language: i18n.language});
// Keep a bounded local history; no network requests or reading telemetry.
browser.storage.local.get(null).then(all => {
const old = Object.keys(all).filter(key => key.startsWith('narouPosition:'))
diff --git a/app/src/main/assets/reader/i18n.js b/app/src/main/assets/reader/i18n.js
new file mode 100644
index 0000000..2036b6a
--- /dev/null
+++ b/app/src/main/assets/reader/i18n.js
@@ -0,0 +1,39 @@
+/* Language packs are bundled extension assets; no remote translation requests. */
+globalThis.NarouI18n = {
+ async create() {
+ const read = async file => {
+ const response = await fetch(browser.runtime.getURL(`langpacks/${file}.json`));
+ if (!response.ok) throw new Error(`Language pack ${file}: ${response.status}`);
+ const value = await response.json();
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Invalid language pack: ${file}`);
+ return value;
+ };
+ const config = await read('config');
+ const supported = config.supportedLanguages;
+ if (!supported || !Object.hasOwn(supported, config.defaultLanguage) ||
+ !Object.keys(supported).every(code => /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/.test(code))) throw new Error('Invalid language configuration');
+ const fallback = await read(config.defaultLanguage);
+ let pack = fallback;
+ const api = {
+ config, language: config.defaultLanguage,
+ text(key, values = {}) {
+ const value = typeof pack[key] === 'string' ? pack[key] : fallback[key];
+ return (typeof value === 'string' ? value : key).replace(/\{(\w+)\}/g, (match, name) => String(values[name] ?? match));
+ },
+ async setLanguage(code) {
+ if (!Object.hasOwn(supported, code)) code = config.defaultLanguage;
+ try {
+ const next = code === config.defaultLanguage ? fallback : await read(code);
+ pack = next; api.language = code;
+ } catch (error) {
+ console.warn('Could not load language pack', error);
+ pack = fallback; api.language = config.defaultLanguage;
+ }
+ }
+ };
+ let stored;
+ try { stored = (await browser.storage.local.get('narouTategaki')).narouTategaki?.language; } catch (_) {}
+ await api.setLanguage(stored || config.defaultLanguage);
+ return api;
+ }
+};
diff --git a/app/src/main/assets/reader/langpacks/config.json b/app/src/main/assets/reader/langpacks/config.json
new file mode 100644
index 0000000..f13a535
--- /dev/null
+++ b/app/src/main/assets/reader/langpacks/config.json
@@ -0,0 +1,7 @@
+{
+ "defaultLanguage": "en",
+ "supportedLanguages": {
+ "en": "English",
+ "ja": "日本語"
+ }
+}
diff --git a/app/src/main/assets/reader/langpacks/en.json b/app/src/main/assets/reader/langpacks/en.json
new file mode 100644
index 0000000..112434c
--- /dev/null
+++ b/app/src/main/assets/reader/langpacks/en.json
@@ -0,0 +1,89 @@
+{
+ "reader.name": "Vertical Reader",
+ "reader.launch": "Read vertically",
+ "reader.shortcut": "Vertical Reader (Alt+Shift+V)",
+ "font": "Font",
+ "spacing": "Columns",
+ "paragraph": "Paragraphs",
+ "theme": "Theme",
+ "paper": "Paper",
+ "white": "White",
+ "dark": "Dark",
+ "auto": "Auto-open",
+ "auto.hint": "Open chapters in vertical mode",
+ "normalColor": "Normal text",
+ "highlightColor": "Focus text",
+ "resetColors": "Reset colors",
+ "appMenu": "App menu",
+ "original": "Original page",
+ "read": "Continue reading",
+ "tilt.off": "Tilt: OFF",
+ "tilt.on": "Tilt: ON",
+ "tilt.settings": "Tilt scrolling",
+ "tilt.hint": "Enable tilt, then set neutral at your reading angle.",
+ "tilt.gauge": "Live tilt and braking range",
+ "tilt.resume": "Resume preview",
+ "tilt.pause": "Pause preview",
+ "tilt.neutral": "Set neutral",
+ "tilt.offset": "Neutral offset",
+ "tilt.acceleration": "Acceleration",
+ "tilt.speed": "Max speed",
+ "tilt.deadzone": "Brake range ±",
+ "tilt.brake": "Braking",
+ "tilt.reverse": "Reverse direction",
+ "tilt.help": "Shaded: brake. Outside: accelerate. Preview scrolls while tuning; pause stops scrolling, not the marker.",
+ "viewport": "Chapter text. Left arrow or Space advances.",
+ "preface": "Preface",
+ "afterword": "Afterword",
+ "nav": "Chapter navigation",
+ "back": "Previous column",
+ "forward": "Next column",
+ "progress": "Reading position",
+ "hint": "Tap for controls · Swipe right to advance",
+ "language": "Language",
+ "tilt.back": "→ Back",
+ "tilt.forward": "← Forward",
+ "tilt.boundaries": "{left}: < {low} g · Brake: {low}–{high} g · {right}: > {high} g",
+ "paused": "Paused",
+ "touch": "Paused — touch",
+ "braking": "Braking",
+ "accelerate.forward": "← Accelerating",
+ "accelerate.back": "→ Accelerating",
+ "tilt.status": "Tilt {x} g · {state} · {velocity} px/s",
+ "tilt.range": "Tilt {x} g; brake {low}–{high} g; {state}",
+ "sensor.missing": "No accelerometer available.",
+ "sensor.wait": "Waiting for sensor.",
+ "sensor.error": "Sensor unavailable: {detail}",
+ "sensor.off": "Tilt scrolling off.",
+ "native.menu": "Reader menu",
+ "native.stopped": "Reader stopped",
+ "native.stopped.help": "The web engine stopped. Reopen this chapter to continue.",
+ "native.reopen": "Reopen",
+ "native.close": "Close",
+ "native.hex": "Hex color, #RRGGBB",
+ "native.red": "Red",
+ "native.green": "Green",
+ "native.blue": "Blue",
+ "native.color": "Text color",
+ "native.apply": "Apply",
+ "native.cancel": "Cancel",
+ "native.hex.error": "Use #RRGGBB, for example #79dfb5",
+ "native.choose": "Choose",
+ "native.load.error": "Could not load the page. Check your connection, then use Menu → Reload.",
+ "native.focus.hint": "Tap a paragraph to focus. Tap it again or empty space for controls.",
+ "native.start.error": "Reader could not start",
+ "native.extension.error": "The built-in extension could not be loaded. Restart the app.\n\n",
+ "native.open.url": "Open novel URL…",
+ "native.reload": "Reload",
+ "native.share": "Share chapter",
+ "native.browser": "Open in browser",
+ "native.about": "About / help",
+ "native.licenses": "Engine licenses",
+ "native.ok": "OK",
+ "native.continue": "Continue reading",
+ "native.open.title": "Open a Narou novel",
+ "native.open": "Open",
+ "native.url.error": "Use an https://ncode.syosetu.com/ URL",
+ "native.browser.error": "No browser is available.",
+ "native.help": "Swipe right to advance. Tap a paragraph to focus; tap again or empty space for controls.\n\nAdjust font, spacing, theme and text colors in the reader controls. Enable tilt, set neutral, then continue reading.\n\nChapter, reading position and preferences are saved on this device.\n\nPowered by Mozilla GeckoView 158 (MPL 2.0). No analytics. Website cookies use a separate app profile."
+}
diff --git a/app/src/main/assets/reader/langpacks/ja.json b/app/src/main/assets/reader/langpacks/ja.json
new file mode 100644
index 0000000..cf4d978
--- /dev/null
+++ b/app/src/main/assets/reader/langpacks/ja.json
@@ -0,0 +1,89 @@
+{
+ "reader.name": "縦書き Reader",
+ "reader.launch": "縦書き",
+ "reader.shortcut": "縦書き Reader (Alt+Shift+V)",
+ "font": "文字",
+ "spacing": "行間",
+ "paragraph": "段落",
+ "theme": "配色",
+ "paper": "紙",
+ "white": "白",
+ "dark": "黒",
+ "auto": "自動",
+ "auto.hint": "章を自動で縦書き表示",
+ "normalColor": "通常の文字",
+ "highlightColor": "注目の文字",
+ "resetColors": "文字色を戻す",
+ "appMenu": "アプリメニュー",
+ "original": "元のページ",
+ "read": "読書に戻る",
+ "tilt.off": "傾き: OFF",
+ "tilt.on": "傾き: ON",
+ "tilt.settings": "傾きスクロール",
+ "tilt.hint": "読みやすい角度で傾きを有効にし、基準を設定。",
+ "tilt.gauge": "現在の傾きと停止範囲",
+ "tilt.resume": "プレビュー再開",
+ "tilt.pause": "プレビュー停止",
+ "tilt.neutral": "この傾きを基準に",
+ "tilt.offset": "停止範囲の中心",
+ "tilt.acceleration": "加速",
+ "tilt.speed": "最高速度",
+ "tilt.deadzone": "停止範囲 ±",
+ "tilt.brake": "ブレーキ",
+ "tilt.reverse": "方向を逆転",
+ "tilt.help": "範囲内で停止、範囲外で加速。プレビューで調整。停止中も傾きを表示。",
+ "viewport": "本文。左矢印またはスペースで読み進めます。",
+ "preface": "前書き",
+ "afterword": "後書き",
+ "nav": "章移動",
+ "back": "前の列",
+ "forward": "次の列",
+ "progress": "読書位置",
+ "hint": "タップで操作 · 右へスワイプで進む",
+ "language": "言語",
+ "tilt.back": "→ 戻る",
+ "tilt.forward": "← 進む",
+ "tilt.boundaries": "{left}: < {low} g · 停止: {low}~{high} g · {right}: > {high} g",
+ "paused": "一時停止",
+ "touch": "タッチで停止",
+ "braking": "減速中",
+ "accelerate.forward": "← 加速中",
+ "accelerate.back": "→ 加速中",
+ "tilt.status": "傾き {x} g · {state} · {velocity} px/s",
+ "tilt.range": "傾き {x} g; 停止 {low}~{high} g; {state}",
+ "sensor.missing": "加速度センサーがありません。",
+ "sensor.wait": "センサー待機中。",
+ "sensor.error": "センサー接続エラー: {detail}",
+ "sensor.off": "傾きスクロールは無効。",
+ "native.menu": "読書メニュー",
+ "native.stopped": "読書エンジン停止",
+ "native.stopped.help": "エンジンが停止しました。章を開き直してください。",
+ "native.reopen": "開き直す",
+ "native.close": "閉じる",
+ "native.hex": "色コード #RRGGBB",
+ "native.red": "赤",
+ "native.green": "緑",
+ "native.blue": "青",
+ "native.color": "文字色",
+ "native.apply": "適用",
+ "native.cancel": "キャンセル",
+ "native.hex.error": "#RRGGBB形式で入力(例: #79dfb5)",
+ "native.choose": "選択",
+ "native.load.error": "読み込み失敗。接続を確認してメニューから再読み込み。",
+ "native.focus.hint": "段落をタップして注目。再タップか余白で操作を表示。",
+ "native.start.error": "起動できませんでした",
+ "native.extension.error": "拡張機能を読み込めません。アプリを再起動してください。\n\n",
+ "native.open.url": "小説URLを開く…",
+ "native.reload": "再読み込み",
+ "native.share": "章を共有",
+ "native.browser": "ブラウザーで開く",
+ "native.about": "情報・ヘルプ",
+ "native.licenses": "エンジンライセンス",
+ "native.ok": "OK",
+ "native.continue": "読書に戻る",
+ "native.open.title": "なろう小説を開く",
+ "native.open": "開く",
+ "native.url.error": "https://ncode.syosetu.com/ のURLを入力",
+ "native.browser.error": "ブラウザーがありません。",
+ "native.help": "右へスワイプで進む。段落をタップして注目、再タップか余白で操作を表示。\n\n文字・行間・配色・文字色を調整できます。傾きを有効にし、基準を設定して読書に戻ります。\n\n章・読書位置・設定は端末に保存されます。\n\nMozilla GeckoView 158(MPL 2.0)を使用。解析なし。Cookieはアプリ専用プロファイルに保存。"
+}
diff --git a/app/src/main/assets/reader/manifest.json b/app/src/main/assets/reader/manifest.json
index 8e8f7ec..59061dd 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.2.0",
+ "name": "\u7e26\u66f8\u304d Reader \u2014 Android",
+ "version": "1.3.0",
"description": "Built-in vertical reading view for Narou Reader.",
"browser_specific_settings": {
"gecko": {
@@ -25,10 +25,14 @@
"https://ncode.syosetu.com/*"
],
"js": [
+ "i18n.js",
"motion.js",
"content.js"
],
"run_at": "document_idle"
}
+ ],
+ "web_accessible_resources": [
+ "langpacks/*.json"
]
}
diff --git a/app/src/main/java/net/astropenguin/naroureader/LanguagePacks.java b/app/src/main/java/net/astropenguin/naroureader/LanguagePacks.java
new file mode 100644
index 0000000..be964b2
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/naroureader/LanguagePacks.java
@@ -0,0 +1,61 @@
+package net.astropenguin.naroureader;
+
+import android.content.Context;
+import org.json.JSONObject;
+import java.io.InputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+
+/** Uses the same bundled JSON packs as the reader extension. */
+final class LanguagePacks {
+ private final Context context;
+ private final JSONObject supported, fallback;
+ private final String defaultLanguage;
+ private JSONObject selected;
+ private String language;
+
+ LanguagePacks(Context context, String savedLanguage) {
+ this.context = context;
+ try {
+ JSONObject config = read("config");
+ supported = config.getJSONObject("supportedLanguages");
+ defaultLanguage = config.getString("defaultLanguage");
+ if (!supported.has(defaultLanguage)) throw new IllegalArgumentException("Unsupported default language");
+ java.util.Iterator<String> codes = supported.keys();
+ while (codes.hasNext()) {
+ if (!codes.next().matches("[a-z]{2,3}(?:-[A-Za-z0-9]+)*")) throw new IllegalArgumentException("Invalid language code");
+ }
+ fallback = read(defaultLanguage);
+ } catch (Exception error) {
+ throw new IllegalStateException("Invalid bundled language configuration", error);
+ }
+ select(savedLanguage);
+ }
+
+ void select(String code) {
+ if (code == null || !supported.has(code)) code = defaultLanguage;
+ try {
+ selected = code.equals(defaultLanguage) ? fallback : read(code);
+ language = code;
+ } catch (Exception error) {
+ selected = fallback;
+ language = defaultLanguage;
+ }
+ }
+
+ String language() { return language; }
+ String text(String key) {
+ Object value = selected.opt(key);
+ return value instanceof String ? (String) value : fallback.optString(key, key);
+ }
+
+ private JSONObject read(String code) throws Exception {
+ try (InputStream input = context.getAssets().open("reader/langpacks/" + code + ".json");
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ byte[] buffer = new byte[4096];
+ int count;
+ while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
+ return new JSONObject(new String(output.toByteArray(), StandardCharsets.UTF_8));
+ }
+ }
+}
diff --git a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
index 2b73b67..da91c60 100644
--- a/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/naroureader/MainActivity.java
@@ -47,11 +47,14 @@ public final class MainActivity extends Activity {
private AlertDialog menuDialog;
private TiltSensor tilt;
private boolean resumed;
+ private LanguagePacks languages;
+ private String tr(String key) { return languages.text(key); }
@Override public void onCreate(Bundle state) {
super.onCreate(state);
tilt = new TiltSensor(this);
prefs = getSharedPreferences("reader", MODE_PRIVATE);
+ languages = new LanguagePacks(this, prefs.getString("readerLanguage", null));
currentUrl = prefs.getString("lastUrl", DEFAULT_URL);
pendingUrl = intentUrl(getIntent());
if (pendingUrl == null) pendingUrl = currentUrl;
@@ -73,7 +76,7 @@ public final class MainActivity extends Activity {
menuButton.setText("☰");
menuButton.setTextSize(22);
menuButton.setTextColor(Color.WHITE);
- menuButton.setContentDescription("Reader menu");
+ menuButton.setContentDescription(tr("native.menu"));
GradientDrawable bg = new GradientDrawable();
bg.setColor(Color.rgb(37, 72, 63)); bg.setCornerRadius(dp(28));
menuButton.setBackground(bg); menuButton.setElevation(dp(5));
@@ -97,9 +100,9 @@ public final class MainActivity extends Activity {
session.setContentDelegate(new GeckoSession.ContentDelegate() {
@Override public void onCrash(GeckoSession s) {
reading = false; tilt.stop(); menuButton.setVisibility(View.VISIBLE);
- new AlertDialog.Builder(MainActivity.this).setTitle("Reader stopped")
- .setMessage("The web engine stopped. Reopen this chapter to continue.")
- .setPositiveButton("Reopen", (d, w) -> recreate()).setNegativeButton("Close", (d, w) -> finish()).show();
+ new AlertDialog.Builder(MainActivity.this).setTitle(tr("native.stopped"))
+ .setMessage(tr("native.stopped.help"))
+ .setPositiveButton(tr("native.reopen"), (d, w) -> recreate()).setNegativeButton(tr("native.close"), (d, w) -> finish()).show();
}
});
session.setPromptDelegate(new GeckoSession.PromptDelegate() {
@@ -112,7 +115,7 @@ public final class MainActivity extends Activity {
picker.addView(swatch, new android.widget.LinearLayout.LayoutParams(-1, dp(48)));
EditText hex = new EditText(MainActivity.this);
hex.setSingleLine(true); hex.setHint("#RRGGBB");
- hex.setContentDescription("Hex color, #RRGGBB");
+ hex.setContentDescription(tr("native.hex"));
hex.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
picker.addView(hex);
String initial = prompt.defaultValue;
@@ -120,7 +123,7 @@ public final class MainActivity extends Activity {
int color = Color.parseColor(initial);
android.widget.SeekBar[] channels = new android.widget.SeekBar[3];
int[] values = {Color.red(color), Color.green(color), Color.blue(color)};
- String[] names = {"Red", "Green", "Blue"};
+ String[] names = {tr("native.red"), tr("native.green"), tr("native.blue")};
for (int i = 0; i < 3; i++) {
android.widget.TextView label = new android.widget.TextView(MainActivity.this);
label.setText(names[i]); picker.addView(label);
@@ -153,8 +156,8 @@ public final class MainActivity extends Activity {
android.widget.ScrollView scroll = new android.widget.ScrollView(MainActivity.this);
scroll.addView(picker);
AlertDialog dialog = new AlertDialog.Builder(MainActivity.this)
- .setTitle("Text color").setView(scroll)
- .setPositiveButton("Apply", null).setNegativeButton("Cancel", null).create();
+ .setTitle(tr("native.color")).setView(scroll)
+ .setPositiveButton(tr("native.apply"), null).setNegativeButton(tr("native.cancel"), null).create();
prompt.setDelegate(new PromptInstanceDelegate() {
@Override public void onPromptDismiss(BasePrompt p) { dialog.dismiss(); }
});
@@ -166,7 +169,7 @@ public final class MainActivity extends Activity {
dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
String value = hex.getText().toString().trim();
- if (!value.matches("#[0-9a-fA-F]{6}")) { hex.setError("Use #RRGGBB, for example #79dfb5"); return; }
+ if (!value.matches("#[0-9a-fA-F]{6}")) { hex.setError(tr("native.hex.error")); return; }
if (!prompt.isComplete()) result.complete(prompt.confirm(value));
dialog.dismiss();
});
@@ -187,11 +190,11 @@ public final class MainActivity extends Activity {
}
GeckoResult<PromptResponse> result = new GeckoResult<>();
AlertDialog dialog = new AlertDialog.Builder(MainActivity.this)
- .setTitle(prompt.title == null ? "Choose" : prompt.title)
+ .setTitle(prompt.title == null ? tr("native.choose") : prompt.title)
.setSingleChoiceItems(labels, selected, (d, which) -> {
if (!prompt.isComplete()) result.complete(prompt.confirm(choices.get(which)));
d.dismiss();
- }).setNegativeButton("Cancel", (d, which) -> {
+ }).setNegativeButton(tr("native.cancel"), (d, which) -> {
if (!prompt.isComplete()) result.complete(prompt.dismiss());
}).create();
dialog.setOnCancelListener(d -> { if (!prompt.isComplete()) result.complete(prompt.dismiss()); });
@@ -229,7 +232,7 @@ public final class MainActivity extends Activity {
geckoView.post(() -> {
progress.setVisibility(View.GONE);
menuButton.setVisibility(View.VISIBLE);
- Toast.makeText(MainActivity.this, "Could not load the page. Check your connection, then use Menu → Reload.", Toast.LENGTH_LONG).show();
+ Toast.makeText(MainActivity.this, tr("native.load.error"), Toast.LENGTH_LONG).show();
});
return null; // Gecko's error page preserves certificate and network error details.
}
@@ -255,6 +258,11 @@ public final class MainActivity extends Activity {
data.optBoolean("enabled") && reading && resumed && hasWindowFocus(),
data.optBoolean("moving")));
}
+ if ("language".equals(data.optString("type"))) {
+ languages.select(data.optString("language"));
+ prefs.edit().putString("readerLanguage", languages.language()).apply();
+ menuButton.setContentDescription(tr("native.menu"));
+ }
if ("menu".equals(data.optString("type"))) showMenu();
if ("theme".equals(data.optString("type"))) applyReaderTheme(data.optString("theme", "paper"));
if ("reader".equals(data.optString("type"))) {
@@ -263,7 +271,7 @@ public final class MainActivity extends Activity {
menuButton.setVisibility(reading ? View.GONE : View.VISIBLE);
if (reading && !prefs.getBoolean("hintShown", false)) {
prefs.edit().putBoolean("hintShown", true).apply();
- Toast.makeText(MainActivity.this, "Tap a paragraph to focus. Tap it again or empty space for controls.", Toast.LENGTH_LONG).show();
+ Toast.makeText(MainActivity.this, tr("native.focus.hint"), Toast.LENGTH_LONG).show();
}
}
return GeckoResult.fromValue(null);
@@ -274,9 +282,9 @@ public final class MainActivity extends Activity {
}, error -> {
if (destroyed) return;
progress.setVisibility(View.GONE);
- new AlertDialog.Builder(this).setTitle("Reader could not start")
- .setMessage("The built-in extension could not be loaded. Restart the app.\n\n" + error.getMessage())
- .setPositiveButton("Close", (d, w) -> finish()).show();
+ new AlertDialog.Builder(this).setTitle(tr("native.start.error"))
+ .setMessage(tr("native.extension.error") + error.getMessage())
+ .setPositiveButton(tr("native.close"), (d, w) -> finish()).show();
});
}
@@ -291,21 +299,21 @@ public final class MainActivity extends Activity {
private void showMenu() {
if (destroyed || (menuDialog != null && menuDialog.isShowing())) return;
- String[] actions = {"Open novel URL…", "Reload", "Share chapter", "Open in browser", "About / help"};
- menuDialog = new AlertDialog.Builder(this).setTitle("縦書き Reader")
+ String[] actions = {tr("native.open.url"), tr("native.reload"), tr("native.share"), tr("native.browser"), tr("native.about")};
+ menuDialog = new AlertDialog.Builder(this).setTitle(tr("reader.name"))
.setItems(actions, (dialog, which) -> {
if (which == 0) openUrlDialog();
if (which == 1 && ready) session.reload();
if (which == 2) {
Intent share = new Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, currentUrl);
- startActivity(Intent.createChooser(share, "Share chapter"));
+ startActivity(Intent.createChooser(share, tr("native.share")));
}
if (which == 3) openExternal(currentUrl);
- if (which == 4) new AlertDialog.Builder(this).setTitle("縦書き Reader " + BuildConfig.VERSION_NAME)
- .setMessage("Swipe right to read forward. Tap a paragraph to focus. Tap it again or empty space for controls.\n\n文字: font size\n行間: column spacing\n段落: paragraph spacing\n配色: theme\nNormal / Focus text: saved text colors\n傾き: accelerometer scrolling; enable, set neutral, then return to reading\n自動: open reader automatically\n\nEmpty spacer paragraphs are removed. The last chapter and reading position are saved on this device.\n\nPowered by Mozilla GeckoView 158. No analytics. Website content and cookies stay in this app’s separate browser profile. GeckoView: MPL 2.0. Engine license details are in the supplied source package.")
- .setNeutralButton("Engine licenses", (d, w) -> session.loadUri("about:license"))
- .setPositiveButton("OK", null).show();
- }).setNegativeButton("Continue reading", null).create();
+ if (which == 4) new AlertDialog.Builder(this).setTitle(tr("reader.name") + " " + BuildConfig.VERSION_NAME)
+ .setMessage(tr("native.help"))
+ .setNeutralButton(tr("native.licenses"), (d, w) -> session.loadUri("about:license"))
+ .setPositiveButton(tr("native.ok"), null).show();
+ }).setNegativeButton(tr("native.continue"), null).create();
menuDialog.setOnDismissListener(d -> immersive());
menuDialog.show();
}
@@ -317,13 +325,13 @@ public final class MainActivity extends Activity {
int pad = dp(20);
FrameLayout wrap = new FrameLayout(this); wrap.setPadding(pad, dp(8), pad, 0);
wrap.addView(field, new FrameLayout.LayoutParams(-1, -2));
- AlertDialog dialog = new AlertDialog.Builder(this).setTitle("Open a Narou novel")
- .setView(wrap).setPositiveButton("Open", null).setNegativeButton("Cancel", null).create();
+ AlertDialog dialog = new AlertDialog.Builder(this).setTitle(tr("native.open.title"))
+ .setView(wrap).setPositiveButton(tr("native.open"), null).setNegativeButton(tr("native.cancel"), null).create();
dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
String url = field.getText().toString().trim();
if (url.startsWith("ncode.syosetu.com/")) url = "https://" + url;
if (!isInternal(url) || !"ncode.syosetu.com".equals(Uri.parse(url).getHost())) {
- field.setError("Use an https://ncode.syosetu.com/ URL"); return;
+ field.setError(tr("native.url.error")); return;
}
pendingUrl = url;
if (ready) session.loadUri(url);
@@ -363,7 +371,7 @@ public final class MainActivity extends Activity {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
intent.setSelector(new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_BROWSER));
try { startActivity(intent); }
- catch (ActivityNotFoundException e) { Toast.makeText(this, "No browser is available.", Toast.LENGTH_SHORT).show(); }
+ catch (ActivityNotFoundException e) { Toast.makeText(this, tr("native.browser.error"), Toast.LENGTH_SHORT).show(); }
}
@Override protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); setIntent(intent);
diff --git a/tests/i18n.test.cjs b/tests/i18n.test.cjs
new file mode 100644
index 0000000..74e9202
--- /dev/null
+++ b/tests/i18n.test.cjs
@@ -0,0 +1,43 @@
+const assert = require('assert').strict;
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const assets = path.resolve(__dirname, '../app/src/main/assets/reader');
+const config = JSON.parse(fs.readFileSync(path.join(assets, 'langpacks/config.json')));
+const packs = Object.fromEntries(Object.keys(config.supportedLanguages).map(code =>
+ [code, JSON.parse(fs.readFileSync(path.join(assets, `langpacks/${code}.json`)))]));
+const source = fs.readFileSync(path.join(assets, 'i18n.js'), 'utf8');
+const content = fs.readFileSync(path.join(assets, 'content.js'), 'utf8');
+const native = fs.readFileSync(path.join(assets, '../../java/net/astropenguin/naroureader/MainActivity.java'), 'utf8');
+for (const pack of Object.values(packs)) {
+ assert.deepEqual(Object.keys(pack).sort(), Object.keys(packs.en).sort());
+ for (const match of content.matchAll(/'@([^']+)'/g)) assert.equal(typeof pack[match[1]], 'string', match[1]);
+ for (const match of native.matchAll(/tr\("([^"]+)"\)/g)) assert.equal(typeof pack[match[1]], 'string', match[1]);
+}
+async function load(saved, broken = false, partial = false) {
+ const requests = [];
+ const scope = {
+ console: {warn() {}},
+ browser: {runtime: {getURL: file => file}, storage: {local: {get: async () => ({narouTategaki: {language: saved}})}}},
+ fetch: async file => {
+ requests.push(file);
+ const code = path.basename(file, '.json');
+ if (broken && code === 'ja') throw new Error('missing pack');
+ return {ok: true, json: async () => code === 'config' ? config : partial && code === 'ja' ? {font: '文字'} : packs[code]};
+ }
+ };
+ vm.createContext(scope); vm.runInContext(source, scope);
+ return {api: await scope.NarouI18n.create(), requests};
+}
+(async () => {
+ let {api, requests} = await load('ja');
+ assert.equal(api.language, 'ja'); assert.equal(api.text('font'), '文字');
+ assert.equal(api.text('sensor.error', {detail: '<unsafe>'}), 'センサー接続エラー: <unsafe>');
+ await api.setLanguage('en'); assert.equal(api.text('font'), 'Font');
+ assert(requests.every(file => /^langpacks\/(config|en|ja)\.json$/.test(file)));
+ ({api, requests} = await load('../../remote'));
+ assert.equal(api.language, 'en'); assert(!requests.some(file => file.includes('remote')));
+ ({api} = await load('ja', true)); assert.equal(api.language, 'en');
+ ({api} = await load('ja', false, true)); assert.equal(api.text('theme'), 'Theme');
+ console.log('PASS: pack parity, native/reader keys, saved selection, interpolation, safe paths, failed-pack and missing-key fallback');
+})().catch(error => {console.error(error); process.exitCode = 1;});
diff --git a/tests/reader.test.cjs b/tests/reader.test.cjs
index 1f5f8f5..d1e10f0 100644
--- a/tests/reader.test.cjs
+++ b/tests/reader.test.cjs
@@ -10,12 +10,12 @@ const ROOT=path.resolve(__dirname,'..');
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.addInitScript(()=>{window.browser={storage:{local:{get:window.readStore,set:window.writeStore,remove:window.removeStore}},runtime:{sendNativeMessage:window.nativeMessage,onMessage:{addListener:fn=>{window.toggleReader=fn;}}}};});
+ 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>';
- await context.route('**/*',r=>r.request().url()==='https://ncode.syosetu.com/n0552ep/75/'?r.fulfill({contentType:'text/html',body:html}):r.abort());
+ await context.route('**/*',r=>{const url=r.request().url();if(url.startsWith('https://ncode.syosetu.com/__reader/langpacks/'))return r.fulfill({contentType:'application/json',body:fs.readFileSync(path.join(ROOT,'app/src/main/assets/reader/langpacks',url.split('/').pop()),'utf8')});return url==='https://ncode.syosetu.com/n0552ep/75/'?r.fulfill({contentType:'text/html',body:html}):r.abort();});
const script=path.join(ROOT,'app/src/main/assets/reader/content.js');
- async function load(){await page.goto('https://ncode.syosetu.com/n0552ep/75/',{waitUntil:'domcontentloaded'});await page.addScriptTag({path:path.join(path.dirname(script),'motion.js')});await page.addScriptTag({path:script});await page.locator('#narou-tategaki-host #panel').waitFor({state:'visible'});await page.waitForTimeout(250);}
+ async function load(){await page.goto('https://ncode.syosetu.com/n0552ep/75/',{waitUntil:'domcontentloaded'});await page.addScriptTag({path:path.join(path.dirname(script),'i18n.js')});await page.addScriptTag({path:path.join(path.dirname(script),'motion.js')});await page.addScriptTag({path:script});await page.locator('#narou-tategaki-host #panel').waitFor({state:'visible'});await page.waitForTimeout(250);}
await load();
const host=page.locator('#narou-tategaki-host'),panel=host.locator('#panel'),v=host.locator('#viewport');
assert((await panel.getAttribute('class')).includes('chrome-hidden'));
@@ -45,12 +45,12 @@ const ROOT=path.resolve(__dirname,'..');
assert.equal(await v.evaluate(e=>e.scrollWidth),width,'controls must not reflow text');
await panel.locator('#paragraph').evaluate(e=>{e.value='1.2';e.dispatchEvent(new Event('input'));});
await page.waitForTimeout(100);assert.equal(data.narouTategaki.paragraph,1.2);
- await panel.locator('select').selectOption('white');
+ await panel.locator('select').first().selectOption('white');
assert(messages.some(m=>m.type==='theme'&&m.theme==='white'));
- await panel.locator('select').selectOption('dark');
+ await panel.locator('select').first().selectOption('dark');
assert(messages.some(m=>m.type==='theme'&&m.theme==='dark'));
await panel.getByRole('button',{name:'App menu'}).click();assert(messages.some(m=>m.type==='menu'));
- await panel.getByRole('button',{name:'読書に戻る'}).click();
+ await panel.getByRole('button',{name:'Continue reading'}).click();
await v.focus();await page.keyboard.press('PageDown');await page.keyboard.press('PageDown');
await page.waitForTimeout(400);
const before=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));assert(before>0);
@@ -62,8 +62,23 @@ const ROOT=path.resolve(__dirname,'..');
assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755','custom colors survive reload and theme change');
assert(messages.filter(m=>m.type==='theme'&&m.theme==='dark').length>=2,'restored theme must reach the native app');
await page.touchscreen.tap(5,5);
+ // Language switches update existing controls without losing focus, colors or position.
+ const language=panel.locator('#reading-language');
+ const positionBeforeLanguage=await v.evaluate(e=>e.scrollLeft);
+ await language.selectOption('ja');
+ await page.waitForFunction(()=>document.querySelector('#narou-tategaki-host').shadowRoot.querySelector('#panel').lang==='ja');
+ assert.equal(await panel.locator('#reading-reset-colors').textContent(),'文字色を戻す');
+ assert.equal(data.narouTategaki.language,'ja');
+ assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755');
+ assert.equal(await v.evaluate(e=>e.scrollLeft),positionBeforeLanguage);
+ assert(messages.some(m=>m.type==='language'&&m.language==='ja'));
+ await load();
+ assert.equal(await panel.getAttribute('lang'),'ja','language survives reload');
+ await page.touchscreen.tap(5,5);
+ await language.selectOption('en');
+ await page.waitForFunction(()=>document.querySelector('#narou-tategaki-host').shadowRoot.querySelector('#panel').lang==='en');
// Optional: inspect with a local screenshot here.
- await panel.getByRole('button',{name:'元のページ',exact:true}).click();assert(await panel.isHidden());
+ await panel.getByRole('button',{name:'Original page',exact:true}).click();assert(await panel.isHidden());
assert(messages.some(m=>m.type==='reader'&&!m.open));
await host.locator('#launch').click();await page.waitForTimeout(100);
assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
@@ -76,7 +91,7 @@ const ROOT=path.resolve(__dirname,'..');
await panel.locator('#motion-toggle').click();
await page.waitForTimeout(250);
await panel.locator('#motion-neutral').click();
- await panel.getByRole('button',{name:'読書に戻る'}).click();
+ await panel.getByRole('button',{name:'Continue reading'}).click();
const motionStart=await v.evaluate(e=>e.scrollLeft);
sensor.x=.5;
await page.waitForTimeout(900);
@@ -133,7 +148,7 @@ const ROOT=path.resolve(__dirname,'..');
await page.waitForTimeout(200);
await panel.locator('#motion-neutral').click();
await panel.locator('#motion-reverse').check();
- await panel.getByRole('button',{name:'読書に戻る'}).click();
+ await panel.getByRole('button',{name:'Continue reading'}).click();
sensor.x=.5;
await page.waitForTimeout(800);
assert(await v.evaluate(e=>e.scrollLeft)>collapsed+15,'reverse must change direction');