diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
index 06b8d0be..c5c1e2e4 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
@@ -470,6 +470,16 @@ class NativeBridgePlugin: Plugin {
private var webViewLifecycleManager: WebViewLifecycleManager?
private var traitChangeRegistered = false
+ // Screen-brightness management. `UIScreen.main.brightness` is a *global*
+ // device setting, not a per-window one: once the app writes to it, iOS
+ // suppresses ambient auto-brightness and the override survives backgrounding,
+ // leaving the system stuck at the app's level until the user nudges it
+ // manually (issue #4885). We remember the value that was there before the
+ // first override so we can hand it back whenever the app leaves the
+ // foreground, and re-assert the app's value when it returns.
+ private var appDesiredBrightness: CGFloat?
+ private var systemBrightnessBeforeOverride: CGFloat?
+
@objc public override func load(webview: WKWebView) {
self.webView = webview
logger.log("NativeBridgePlugin loaded")
@@ -536,6 +546,10 @@ class NativeBridgePlugin: Plugin {
@objc func appWillEnterForeground() {
logger.log("NativeBridgePlugin: App will enter foreground")
+ // Re-assert the app's brightness that was released on background (#4885).
+ if let desired = appDesiredBrightness {
+ UIScreen.main.brightness = desired
+ }
webViewLifecycleManager?.handleAppWillEnterForeground()
}
@@ -662,6 +676,11 @@ class NativeBridgePlugin: Plugin {
if let handler = volumeKeyHandler, handler.isIntercepting {
handler.stopInterception()
}
+ // Hand screen brightness back to iOS so ambient auto-brightness resumes
+ // while backgrounded; the override is re-applied on foreground (#4885).
+ if appDesiredBrightness != nil, let original = systemBrightnessBeforeOverride {
+ UIScreen.main.brightness = original
+ }
webViewLifecycleManager?.handleAppDidEnterBackground()
}
@@ -1050,22 +1069,39 @@ class NativeBridgePlugin: Plugin {
let brightness = args.brightness ?? 0.5
- if brightness < 0.0 {
- // Revert to system brightness - iOS doesn't have a direct "system brightness" setting
- // We will restore the brightness that was set before the app modified it
- return invoke.resolve(["success": true])
- }
-
if brightness > 1.0 {
return invoke.reject("Brightness must be between 0.0 and 1.0")
}
- DispatchQueue.main.async {
- UIScreen.main.brightness = CGFloat(brightness)
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ if brightness < 0.0 {
+ // A negative value means "release control back to the system", mirroring
+ // Android's BRIGHTNESS_OVERRIDE_NONE. Restore the pre-override brightness
+ // so iOS resumes ambient auto-brightness.
+ self.releaseBrightnessControl()
+ } else {
+ if self.systemBrightnessBeforeOverride == nil {
+ self.systemBrightnessBeforeOverride = UIScreen.main.brightness
+ }
+ self.appDesiredBrightness = CGFloat(brightness)
+ UIScreen.main.brightness = CGFloat(brightness)
+ }
}
invoke.resolve(["success": true])
}
+ /// Restore the brightness captured before the app first overrode it so iOS
+ /// resumes ambient auto-brightness, then forget our managed state. Must run
+ /// on the main thread.
+ private func releaseBrightnessControl() {
+ if let original = systemBrightnessBeforeOverride {
+ UIScreen.main.brightness = original
+ }
+ appDesiredBrightness = nil
+ systemBrightnessBeforeOverride = nil
+ }
+
@objc public func copy_uri_to_path(_ invoke: Invoke) {
guard let args = try? invoke.parseArgs(CopyUriToPathRequestArgs.self) else {
return invoke.reject("Failed to parse arguments")
diff --git a/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx b/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx
new file mode 100644
index 00000000..7def34f1
--- /dev/null
+++ b/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx
@@ -0,0 +1,90 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+const h = vi.hoisted(() => ({
+ hasScreenBrightness: true,
+ autoScreenBrightness: false,
+ screenBrightness: -1,
+ setScreenBrightness: vi.fn(),
+}));
+
+vi.mock('@/context/EnvContext', () => ({
+ useEnv: () => ({ appService: { hasScreenBrightness: h.hasScreenBrightness } }),
+}));
+vi.mock('@/store/settingsStore', () => ({
+ useSettingsStore: () => ({
+ settings: {
+ autoScreenBrightness: h.autoScreenBrightness,
+ screenBrightness: h.screenBrightness,
+ },
+ }),
+}));
+vi.mock('@/store/deviceStore', () => ({
+ useDeviceControlStore: () => ({ setScreenBrightness: h.setScreenBrightness }),
+}));
+
+import { useScreenBrightness } from '@/app/reader/hooks/useScreenBrightness';
+
+function Wrapper() {
+ useScreenBrightness();
+ return null;
+}
+
+const setup = () => render();
+
+describe('useScreenBrightness', () => {
+ beforeEach(() => {
+ h.hasScreenBrightness = true;
+ h.autoScreenBrightness = false;
+ h.screenBrightness = -1;
+ h.setScreenBrightness.mockReset();
+ });
+ afterEach(() => cleanup());
+
+ it('applies the saved manual brightness when auto is off', () => {
+ h.autoScreenBrightness = false;
+ h.screenBrightness = 40;
+ setup();
+ expect(h.setScreenBrightness).toHaveBeenCalledWith(0.4);
+ });
+
+ it('releases control to the system when auto brightness is on', () => {
+ h.autoScreenBrightness = true;
+ h.screenBrightness = 40;
+ setup();
+ expect(h.setScreenBrightness).toHaveBeenCalledWith(-1);
+ });
+
+ it('releases control when no manual brightness has been set', () => {
+ h.autoScreenBrightness = false;
+ h.screenBrightness = -1;
+ setup();
+ expect(h.setScreenBrightness).toHaveBeenCalledWith(-1);
+ });
+
+ it('releases control on unmount', () => {
+ h.autoScreenBrightness = false;
+ h.screenBrightness = 40;
+ const utils = setup();
+ h.setScreenBrightness.mockClear();
+ utils.unmount();
+ expect(h.setScreenBrightness).toHaveBeenCalledWith(-1);
+ });
+
+ it('re-applies the manual brightness when switching off auto brightness', () => {
+ h.autoScreenBrightness = true;
+ h.screenBrightness = 40;
+ const utils = setup();
+ h.setScreenBrightness.mockClear();
+ h.autoScreenBrightness = false;
+ utils.rerender();
+ expect(h.setScreenBrightness).toHaveBeenLastCalledWith(0.4);
+ });
+
+ it('is inert when the platform lacks screen brightness control', () => {
+ h.hasScreenBrightness = false;
+ h.screenBrightness = 40;
+ setup();
+ expect(h.setScreenBrightness).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx
index 95185cce..273e62f8 100644
--- a/apps/readest-app/src/app/reader/components/Reader.tsx
+++ b/apps/readest-app/src/app/reader/components/Reader.tsx
@@ -15,6 +15,7 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
+import { useScreenBrightness } from '@/app/reader/hooks/useScreenBrightness';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useReplicaPull } from '@/hooks/useReplicaPull';
import { eventDispatcher } from '@/utils/event';
@@ -60,7 +61,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { sideBarBookKey } = useSidebarStore();
const { hoveredBookKey } = useReaderStore();
const { showSystemUI, dismissSystemUI } = useThemeStore();
- const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { isSideBarVisible, isSideBarPinned } = useSidebarStore();
const { getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
@@ -70,6 +70,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
+ useScreenBrightness();
useTransferQueue(libraryLoaded, 5000);
// Reader needs dictionaries for word-lookup, fonts for rendering, and
// textures for the page background. Mounted here (not in the app-
@@ -87,27 +88,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
initDayjs(getLocale());
}, []);
- useEffect(() => {
- const brightness = settings.screenBrightness;
- const autoBrightness = settings.autoScreenBrightness;
- if (appService?.hasScreenBrightness && !autoBrightness && brightness >= 0) {
- setScreenBrightness(brightness / 100);
- }
- let previousBrightness = -1;
- if (appService?.isIOSApp) {
- getScreenBrightness().then((b) => {
- previousBrightness = b;
- });
- }
-
- return () => {
- if (appService?.hasScreenBrightness && !autoBrightness) {
- setScreenBrightness(previousBrightness);
- }
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [appService]);
-
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
const { hoveredBookKey, setHoveredBookKey } = useReaderStore.getState();
diff --git a/apps/readest-app/src/app/reader/hooks/useScreenBrightness.ts b/apps/readest-app/src/app/reader/hooks/useScreenBrightness.ts
new file mode 100644
index 00000000..c614f08a
--- /dev/null
+++ b/apps/readest-app/src/app/reader/hooks/useScreenBrightness.ts
@@ -0,0 +1,45 @@
+import { useEffect } from 'react';
+import { useEnv } from '@/context/EnvContext';
+import { useSettingsStore } from '@/store/settingsStore';
+import { useDeviceControlStore } from '@/store/deviceStore';
+
+// Sentinel handed to the native bridge to release any app override and give
+// screen brightness back to the system: iOS restores the value captured before
+// the override, Android clears BRIGHTNESS_OVERRIDE_NONE. See issue #4885.
+const RELEASE_BRIGHTNESS = -1;
+
+/**
+ * Applies the user's manual reading brightness while the reader is open and
+ * releases control back to the system when the reader closes or the user
+ * switches "System Screen Brightness" back on.
+ *
+ * The native iOS bridge additionally restores the system brightness whenever
+ * the app backgrounds (and re-applies on foreground), so ambient auto-brightness
+ * never stays locked after leaving the app — the reader component does not
+ * unmount when the app is merely sent to the home screen.
+ */
+export const useScreenBrightness = () => {
+ const { appService } = useEnv();
+ const { settings } = useSettingsStore();
+ const { setScreenBrightness } = useDeviceControlStore();
+
+ const hasScreenBrightness = !!appService?.hasScreenBrightness;
+ const { screenBrightness, autoScreenBrightness } = settings;
+
+ useEffect(() => {
+ if (!hasScreenBrightness) return;
+ if (!autoScreenBrightness && screenBrightness >= 0) {
+ setScreenBrightness(screenBrightness / 100);
+ } else {
+ setScreenBrightness(RELEASE_BRIGHTNESS);
+ }
+ return () => {
+ setScreenBrightness(RELEASE_BRIGHTNESS);
+ };
+ // Deliberately not depending on `screenBrightness`: live slider/gesture
+ // updates are pushed by their own handlers, so re-running here would flash
+ // the screen (release then re-apply) mid-drag. We only (re)apply on mount
+ // and when the auto/manual mode flips.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hasScreenBrightness, autoScreenBrightness, setScreenBrightness]);
+};