= ({ visible, level }) => {
+ const eink = isEink();
+ const clamped = Math.max(0, Math.min(1, level));
+ // Fill height tracks the slider's perceptual position; the label shows the value.
+ let fillPercent = valueToPosition(clamped) * 100;
+ let valuePercent = Math.round(clamped * 100);
+ if (eink) {
+ fillPercent = Math.round(fillPercent / 10) * 10;
+ valuePercent = Math.round(valuePercent / 10) * 10;
+ }
+
+ return (
+
+ );
+};
+
+export default BrightnessOverlay;
diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
index 7e4e4285..d0c97075 100644
--- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
+++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
@@ -14,6 +14,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { useParallelViewStore } from '@/store/parallelViewStore';
import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents';
+import { useBrightnessGesture } from '../hooks/useBrightnessGesture';
+import BrightnessOverlay from './BrightnessOverlay';
import { usePagination, viewPagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
@@ -101,6 +103,8 @@ const FoliateViewer: React.FC<{
const { getBookData } = useBookDataStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
+ const { registerBrightnessListeners, overlayVisible, overlayLevel } =
+ useBrightnessGesture(bookKey);
const bookData = getBookData(bookKey);
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -315,6 +319,7 @@ const FoliateViewer: React.FC<{
detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey));
detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey));
addLongPressListeners(bookKey, detail.doc);
+ registerBrightnessListeners(detail.doc);
}
}
};
@@ -796,6 +801,7 @@ const FoliateViewer: React.FC<{
{...mouseHandlers}
{...touchHandlers}
/>
+
{((!docLoaded.current && loading) || viewState?.loading) && (
diff --git a/apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts b/apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts
new file mode 100644
index 00000000..4d6d57a7
--- /dev/null
+++ b/apps/readest-app/src/app/reader/hooks/useBrightnessGesture.ts
@@ -0,0 +1,197 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useEnv } from '@/context/EnvContext';
+import { useReaderStore } from '@/store/readerStore';
+import { useSettingsStore } from '@/store/settingsStore';
+import { useDeviceControlStore } from '@/store/deviceStore';
+import { saveSysSettings } from '@/helpers/settings';
+import {
+ computeBrightness,
+ isInLeftEdge,
+ shouldActivate,
+} from '@/app/reader/utils/brightnessGesture';
+
+const OVERLAY_HIDE_DELAY_MS = 600;
+const DEFAULT_BRIGHTNESS = 0.5;
+
+interface LatestState {
+ enabled: boolean;
+ scrolled: boolean;
+}
+
+/**
+ * Left-edge swipe-to-adjust-brightness gesture (iOS / Android only).
+ *
+ * Attaches capture-phase, non-passive touch listeners to the foliate iframe
+ * document. Capture phase is required: foliate-js's own paginator registers its
+ * touch listeners during `view.open()` (before any app listener) in the bubble
+ * phase, so only a capture-phase `stopImmediatePropagation` can suppress them.
+ *
+ * The listener is attached once per document, so everything runtime-variable is
+ * read through `latestRef` (updated each render), mirroring `useTouchInterceptor`.
+ */
+export const useBrightnessGesture = (bookKey: string) => {
+ const { appService, envConfig } = useEnv();
+ const { settings } = useSettingsStore();
+ const { getViewSettings } = useReaderStore();
+ const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
+
+ const hasScreenBrightness = !!appService?.hasScreenBrightness;
+ const viewSettings = getViewSettings(bookKey);
+
+ const [overlayVisible, setOverlayVisible] = useState(false);
+ const [overlayLevel, setOverlayLevel] = useState(0);
+
+ // Everything the once-attached listener must read at the latest value.
+ const latestRef = useRef({ enabled: false, scrolled: false });
+ latestRef.current = {
+ enabled: hasScreenBrightness && settings.swipeBrightnessGesture,
+ scrolled: !!viewSettings?.scrolled,
+ };
+
+ // Per-gesture state.
+ const armedRef = useRef(false);
+ const activeRef = useRef(false);
+ const startXRef = useRef(0);
+ const startYRef = useRef(0);
+ const viewHeightRef = useRef(0);
+ const startValueRef = useRef(DEFAULT_BRIGHTNESS);
+ const levelRef = useRef(DEFAULT_BRIGHTNESS);
+ const rafIdRef = useRef(null);
+ const pendingValueRef = useRef(null);
+ const hideTimerRef = useRef | null>(null);
+
+ // Seed brightness (0-1): the value a gesture starts from. Primed eagerly so the
+ // first swipe never races the async device read.
+ const seedRef = useRef(DEFAULT_BRIGHTNESS);
+ const seededRef = useRef(false);
+
+ useEffect(() => {
+ if (!hasScreenBrightness) return;
+ if (settings.screenBrightness >= 0) {
+ seedRef.current = Math.max(0, Math.min(1, settings.screenBrightness / 100));
+ seededRef.current = true;
+ return;
+ }
+ let cancelled = false;
+ getScreenBrightness().then((b) => {
+ if (cancelled) return;
+ seedRef.current = b >= 0 && b <= 1 ? b : DEFAULT_BRIGHTNESS;
+ seededRef.current = true;
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [hasScreenBrightness, settings.screenBrightness, getScreenBrightness]);
+
+ const flushBrightness = useCallback(() => {
+ rafIdRef.current = null;
+ if (pendingValueRef.current !== null) {
+ setScreenBrightness(pendingValueRef.current);
+ pendingValueRef.current = null;
+ }
+ }, [setScreenBrightness]);
+
+ const scheduleBrightness = useCallback(
+ (value: number) => {
+ pendingValueRef.current = value;
+ if (rafIdRef.current === null) {
+ rafIdRef.current = requestAnimationFrame(flushBrightness);
+ }
+ },
+ [flushBrightness],
+ );
+
+ const cancelRaf = useCallback(() => {
+ if (rafIdRef.current !== null) {
+ cancelAnimationFrame(rafIdRef.current);
+ rafIdRef.current = null;
+ }
+ pendingValueRef.current = null;
+ }, []);
+
+ const resetGesture = useCallback(() => {
+ armedRef.current = false;
+ activeRef.current = false;
+ }, []);
+
+ const registerBrightnessListeners = useCallback(
+ (doc: Document) => {
+ const opts = { capture: true, passive: false } as const;
+
+ const onTouchStart = (e: TouchEvent) => {
+ resetGesture();
+ if (!latestRef.current.enabled) return;
+ const selection = doc.getSelection?.();
+ if (selection && !selection.isCollapsed) return; // don't hijack selection
+ const t = e.touches[0];
+ if (!t) return;
+ // Use screenX/screenY, not clientX/clientY: in paginated mode foliate-js
+ // lays content out as side-by-side columns, so the iframe document is
+ // many screens wide and clientX is a document coordinate. screenX is the
+ // physical screen position, and this listener runs in the parent realm so
+ // `window` is the app viewport.
+ const viewWidth = window.innerWidth;
+ viewHeightRef.current = window.innerHeight;
+ startXRef.current = t.screenX;
+ startYRef.current = t.screenY;
+ armedRef.current = isInLeftEdge(t.screenX, viewWidth);
+ startValueRef.current = seedRef.current;
+ };
+
+ const onTouchMove = (e: TouchEvent) => {
+ if (!armedRef.current) return;
+ const t = e.touches[0];
+ if (!t) return;
+ const dx = t.screenX - startXRef.current;
+ const dy = t.screenY - startYRef.current;
+ // Reserve the strip in scrolled mode: stop native scroll from the first
+ // move, so there is no scroll-then-freeze jump once brightness activates.
+ if (latestRef.current.scrolled) e.preventDefault();
+ if (!activeRef.current && shouldActivate(dx, dy)) {
+ activeRef.current = true;
+ }
+ if (!activeRef.current) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ const value = computeBrightness(startValueRef.current, dy, viewHeightRef.current);
+ levelRef.current = value;
+ scheduleBrightness(value);
+ setOverlayVisible(true);
+ setOverlayLevel(value);
+ };
+
+ const onTouchEnd = (e: TouchEvent) => {
+ if (!activeRef.current) {
+ resetGesture();
+ return;
+ }
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ cancelRaf();
+ const value = levelRef.current;
+ setScreenBrightness(value);
+ seedRef.current = value;
+ saveSysSettings(envConfig, 'screenBrightness', Math.round(value * 100));
+ saveSysSettings(envConfig, 'autoScreenBrightness', false);
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+ hideTimerRef.current = setTimeout(() => setOverlayVisible(false), OVERLAY_HIDE_DELAY_MS);
+ resetGesture();
+ };
+
+ doc.addEventListener('touchstart', onTouchStart, opts);
+ doc.addEventListener('touchmove', onTouchMove, opts);
+ doc.addEventListener('touchend', onTouchEnd, opts);
+ doc.addEventListener('touchcancel', onTouchEnd, opts);
+ },
+ [resetGesture, scheduleBrightness, cancelRaf, setScreenBrightness, envConfig],
+ );
+
+ useEffect(() => {
+ return () => {
+ cancelRaf();
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+ };
+ }, [cancelRaf]);
+
+ return { registerBrightnessListeners, overlayVisible, overlayLevel };
+};
diff --git a/apps/readest-app/src/app/reader/utils/brightnessGesture.ts b/apps/readest-app/src/app/reader/utils/brightnessGesture.ts
new file mode 100644
index 00000000..13aa1a54
--- /dev/null
+++ b/apps/readest-app/src/app/reader/utils/brightnessGesture.ts
@@ -0,0 +1,58 @@
+/**
+ * Pure helpers for the left-edge swipe-to-adjust-brightness gesture.
+ *
+ * The gesture reserves the left `BRIGHTNESS_GESTURE_EDGE_RATIO` of the rendered
+ * iframe-document width. Once a touch that starts there becomes vertical-dominant
+ * past `BRIGHTNESS_GESTURE_ACTIVATION_PX`, finger travel maps to brightness.
+ *
+ * Brightness math happens in the same perceptual "position" space the menu
+ * slider uses (`ColorPanel.tsx`: position = value^0.5, value = position^2), so
+ * the gesture, the overlay fill, and the slider all agree.
+ */
+
+export const BRIGHTNESS_GESTURE_EDGE_RATIO = 0.1;
+export const BRIGHTNESS_GESTURE_ACTIVATION_PX = 18;
+
+const clamp01 = (v: number): number => Math.max(0, Math.min(1, v));
+
+/** True when `clientX` falls within the left edge strip of the view. */
+export const isInLeftEdge = (
+ clientX: number,
+ viewWidth: number,
+ edgeRatio = BRIGHTNESS_GESTURE_EDGE_RATIO,
+): boolean => viewWidth > 0 && clientX <= viewWidth * edgeRatio;
+
+/** True when movement is vertical-dominant and past the activation threshold. */
+export const shouldActivate = (
+ deltaX: number,
+ deltaY: number,
+ threshold = BRIGHTNESS_GESTURE_ACTIVATION_PX,
+): boolean => Math.abs(deltaY) >= threshold && Math.abs(deltaY) > Math.abs(deltaX);
+
+/** Perceptual position (0-1) for a brightness value (0-1) — matches the slider. */
+export const valueToPosition = (value: number): number => Math.sqrt(clamp01(value));
+
+/** Brightness value (0-1) for a perceptual position (0-1) — matches the slider. */
+export const positionToValue = (position: number): number => {
+ const p = clamp01(position);
+ return clamp01(p * p);
+};
+
+/**
+ * New brightness value after dragging `deltaY` px from `startValue`.
+ *
+ * Up (negative `deltaY`) brightens. A full view-height drag spans the whole
+ * range. Travel is linear in perceptual position, then converted back to a
+ * brightness value so the feel matches the slider. The start is clamped, so an
+ * unseeded/`-1` value is safe.
+ */
+export const computeBrightness = (
+ startValue: number,
+ deltaY: number,
+ viewHeight: number,
+): number => {
+ if (viewHeight <= 0) return clamp01(startValue);
+ const startPos = valueToPosition(clamp01(startValue));
+ const nextPos = clamp01(startPos - deltaY / viewHeight);
+ return positionToValue(nextPos);
+};
diff --git a/apps/readest-app/src/components/settings/ControlPanel.tsx b/apps/readest-app/src/components/settings/ControlPanel.tsx
index 37e01343..94cbb871 100644
--- a/apps/readest-app/src/components/settings/ControlPanel.tsx
+++ b/apps/readest-app/src/components/settings/ControlPanel.tsx
@@ -48,6 +48,9 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes
const [isEink, setIsEink] = useState(viewSettings.isEink);
const [isColorEink, setIsColorEink] = useState(viewSettings.isColorEink);
const [autoScreenBrightness, setAutoScreenBrightness] = useState(settings.autoScreenBrightness);
+ const [swipeBrightnessGesture, setSwipeBrightnessGesture] = useState(
+ settings.swipeBrightnessGesture,
+ );
const [screenWakeLock, setScreenWakeLock] = useState(settings.screenWakeLock);
const [allowScript, setAllowScript] = useState(viewSettings.allowScript);
@@ -191,6 +194,12 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoScreenBrightness]);
+ useEffect(() => {
+ if (swipeBrightnessGesture === settings.swipeBrightnessGesture) return;
+ saveSysSettings(envConfig, 'swipeBrightnessGesture', swipeBrightnessGesture);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [swipeBrightnessGesture]);
+
useEffect(() => {
if (screenWakeLock === settings.screenWakeLock) return;
saveSysSettings(envConfig, 'screenWakeLock', screenWakeLock);
@@ -382,6 +391,15 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes
onChange={() => setAutoScreenBrightness(!autoScreenBrightness)}
/>
)}
+ {appService?.hasScreenBrightness && (
+ setSwipeBrightnessGesture(!swipeBrightnessGesture)}
+ data-setting-id='settings.control.swipeBrightnessGesture'
+ />
+ )}
= {
screenWakeLock: false,
screenBrightness: -1, // -1~100, -1 for system default
autoScreenBrightness: true,
+ swipeBrightnessGesture: true,
hardwarePageTurner: {
enabled: false,
bindings: { pagePrev: null, pageNext: null, sectionPrev: null, sectionNext: null },
diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts
index 6ee3e635..489362a9 100644
--- a/apps/readest-app/src/types/settings.ts
+++ b/apps/readest-app/src/types/settings.ts
@@ -284,6 +284,7 @@ export interface SystemSettings {
screenWakeLock: boolean;
screenBrightness: number;
autoScreenBrightness: boolean;
+ swipeBrightnessGesture: boolean;
hardwarePageTurner: HardwarePageTurnerSettings;
alwaysShowStatusBar: boolean;
alwaysInForeground: boolean;