forked from akai/readest
On iOS `UIScreen.main.brightness` is a global device setting, not a per-window one like Android. Once Readest overrode it (brightness slider or left-edge swipe gesture) the override survived backgrounding, so swiping to the home screen left the system stuck at an extreme level and ambient auto-brightness appeared locked. The only cleanup lived in the reader's unmount effect, which never runs when the app is merely sent to the background, and the native `brightness < 0` "release" branch was a no-op stub. Native (NativeBridgePlugin.swift): capture the system brightness before the first override, restore it on `appDidEnterBackground` so iOS resumes auto-brightness, and re-apply the app's value on `appWillEnterForeground`. Implement the negative-value release path (restore + forget state), mirroring Android's BRIGHTNESS_OVERRIDE_NONE. JS (useScreenBrightness hook, replacing the racy inline Reader effect): apply the manual brightness while reading, release via setScreenBrightness(-1) on unmount and when "System Screen Brightness" is toggled back on. Excludes screenBrightness from deps so live slider/gesture drags don't flash release-then-reapply. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+44
-8
@@ -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")
|
||||
|
||||
@@ -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(<Wrapper />);
|
||||
|
||||
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(<Wrapper />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user