Add "safe area inset" setting to reader

This commit is contained in:
schroda
2026-05-08 01:39:48 +02:00
parent 6d3eb4266b
commit d1e70c2609
15 changed files with 153 additions and 40 deletions

View File

@@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Server Update**) Add an option to disable showing information when the server got updated - (**Server Update**) Add an option to disable showing information when the server got updated
- (**Manga**) Add an option to include client data during migration - (**Manga**) Add an option to include client data during migration
- (**Migrate**) Add bulk migration - (**Migrate**) Add bulk migration
- (**Reader**) Respect the safe inset area (e.g., top-notch) of devices - (**Reader**) Add an option to apply safe area paddings (e.g., top-notch, rounded corners)
### Changed ### Changed

View File

@@ -20,42 +20,34 @@ export enum DirectionOffset {
NEXT = 1, NEXT = 1,
} }
interface DisplayDataTranslation { interface DisplayData {
isTitleString?: never; title: MessageDescriptor | string;
title: MessageDescriptor;
icon: ReactNode; icon: ReactNode;
} }
interface DisplayDataString {
isTitleString: true;
title: string;
icon: ReactNode;
}
type DisplayData = DisplayDataTranslation | DisplayDataString;
export type ValueToDisplayData<Value extends string | number> = Record<Value, DisplayData>; export type ValueToDisplayData<Value extends string | number> = Record<Value, DisplayData>;
export interface MultiValueButtonBaseProps<Value extends string | number> { export interface MultiValueButtonBaseProps<Value extends string | number, MultiValue extends Value | Value[] = Value> {
tooltip?: string; tooltip?: string;
value: Value; value: MultiValue;
defaultValue?: Value; defaultValue?: Value;
values: Value[]; values: Value[];
setValue: (value: Value) => void; setValue: (value: MultiValue) => void;
valueToDisplayData: ValueToDisplayData<Value>; valueToDisplayData: ValueToDisplayData<Value>;
} }
export interface MultiValueButtonDefaultableProps<Value extends string | number> extends OptionalProperty< export interface MultiValueButtonDefaultableProps<
MultiValueButtonBaseProps<Value>, Value extends string | number,
'value' MultiValue extends Value | Value[] = Value,
> { > extends OptionalProperty<MultiValueButtonBaseProps<Value, MultiValue>, 'value'> {
isDefaultable?: boolean; isDefaultable?: boolean;
onDefault?: () => void; onDefault?: () => void;
} }
export type MultiValueButtonProps<Value extends string | number> = export type MultiValueButtonProps<Value extends string | number, MultiValue extends Value | Value[] = Value> =
| (MultiValueButtonBaseProps<Value> & PropertiesNever<MultiValueButtonDefaultableProps<Value>>) | (MultiValueButtonBaseProps<Value, MultiValue> &
| MultiValueButtonDefaultableProps<Value>; PropertiesNever<MultiValueButtonDefaultableProps<Value, MultiValue>>)
| MultiValueButtonDefaultableProps<Value, MultiValue>;
export enum ScrollOffset { export enum ScrollOffset {
BACKWARD, BACKWARD,

View File

@@ -13,7 +13,7 @@ import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import type { MultiValueButtonProps } from '@/base/Base.types.ts'; import type { MultiValueButtonProps } from '@/base/Base.types.ts';
import { Superscript } from '@/base/components/texts/Superscript.tsx'; import { Superscript } from '@/base/components/texts/Superscript.tsx';
export const ButtonSelect = <Value extends string | number>({ export const ButtonSelect = <Value extends string | number, MultiValue extends Value | Value[] = Value>({
value, value,
values, values,
defaultValue, defaultValue,
@@ -21,7 +21,7 @@ export const ButtonSelect = <Value extends string | number>({
valueToDisplayData, valueToDisplayData,
isDefaultable, isDefaultable,
onDefault, onDefault,
}: MultiValueButtonProps<Value>) => { }: MultiValueButtonProps<Value, MultiValue>) => {
const { t } = useLingui(); const { t } = useLingui();
return ( return (
@@ -33,16 +33,31 @@ export const ButtonSelect = <Value extends string | number>({
)} )}
{values.map((displayValue) => { {values.map((displayValue) => {
const isDefault = value === undefined && displayValue === defaultValue; const isDefault = value === undefined && displayValue === defaultValue;
const isMultiSelect = Array.isArray(value);
const isSelected = isMultiSelect ? value.includes(displayValue) : displayValue === value;
const text = valueToDisplayData[displayValue].isTitleString const newValue = (() => {
if (value === undefined) {
return [displayValue];
}
if (isMultiSelect) {
return isSelected ? value.filter((v) => v !== displayValue) : [...value, displayValue];
}
return displayValue;
})() as MultiValue;
const text =
typeof valueToDisplayData[displayValue].title === 'string'
? valueToDisplayData[displayValue].title ? valueToDisplayData[displayValue].title
: t(valueToDisplayData[displayValue].title); : t(valueToDisplayData[displayValue].title);
return ( return (
<CustomTooltip key={displayValue} title={isDefault ? t`Active setting` : ''}> <CustomTooltip key={displayValue} title={isDefault ? t`Active setting` : ''}>
<Button <Button
onClick={() => setValue(displayValue)} onClick={() => setValue(newValue)}
variant={displayValue === value ? 'contained' : 'outlined'} variant={isSelected ? 'contained' : 'outlined'}
startIcon={valueToDisplayData[displayValue].icon} startIcon={valueToDisplayData[displayValue].icon}
> >
{isDefault ? <Superscript superscript="*" text={text} /> : text} {isDefault ? <Superscript superscript="*" text={text} /> : text}

View File

@@ -53,7 +53,7 @@ export const ValueRotationButton = <Value extends string | number>({
<Superscript <Superscript
superscript={`(${t`Default`})`} superscript={`(${t`Default`})`}
text={ text={
valueToDisplayData[defaultValue].isTitleString typeof valueToDisplayData[defaultValue].title === 'string'
? valueToDisplayData[defaultValue].title ? valueToDisplayData[defaultValue].title
: t(valueToDisplayData[defaultValue].title) : t(valueToDisplayData[defaultValue].title)
} }
@@ -77,7 +77,7 @@ export const ValueRotationButton = <Value extends string | number>({
startIcon={valueToDisplayData[value].icon} startIcon={valueToDisplayData[value].icon}
size="large" size="large"
> >
{valueToDisplayData[value].isTitleString {typeof valueToDisplayData[value].title === 'string'
? valueToDisplayData[value].title ? valueToDisplayData[value].title
: t(valueToDisplayData[value].title)} : t(valueToDisplayData[value].title)}
</Button> </Button>

View File

@@ -12,11 +12,11 @@ import type { ComponentProps } from 'react';
import { ButtonSelect } from '@/base/components/buttons/ButtonSelect.tsx'; import { ButtonSelect } from '@/base/components/buttons/ButtonSelect.tsx';
export const ButtonSelectInput = <Value extends string | number>({ export const ButtonSelectInput = <Value extends string | number, MultiValue extends Value | Value[] = Value>({
label, label,
description, description,
...buttonSelectProps ...buttonSelectProps
}: ComponentProps<typeof ButtonSelect<Value>> & { label: string; description?: string }) => ( }: ComponentProps<typeof ButtonSelect<Value, MultiValue>> & { label: string; description?: string }) => (
<Stack> <Stack>
<Typography>{label}</Typography> <Typography>{label}</Typography>
{description && ( {description && (

View File

@@ -8,7 +8,7 @@
import deepmerge from '@mui/utils/deepmerge'; import deepmerge from '@mui/utils/deepmerge';
import type { AppMetadataKeys, IMetadataMigration } from '@/features/metadata/Metadata.types.ts'; import type { AppMetadataKeys, IMetadataMigration } from '@/features/metadata/Metadata.types.ts';
import type { IReaderSettings, ReaderCustomFilter } from '@/features/reader/Reader.types.ts'; import type { IReaderSettings, ReaderCustomFilter, SafeAreaInset } from '@/features/reader/Reader.types.ts';
import { ProgressBarPosition, ReaderPageScaleMode, ReadingMode } from '@/features/reader/Reader.types.ts'; import { ProgressBarPosition, ReaderPageScaleMode, ReadingMode } from '@/features/reader/Reader.types.ts';
import { import {
AUTO_SCROLL_SPEED, AUTO_SCROLL_SPEED,
@@ -394,6 +394,9 @@ export const APP_METADATA: Record<
return locale; return locale;
}, },
}, },
safeAreaInset: {
convert: convertToObject<SafeAreaInset>,
},
} as const; } as const;
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA); export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA);

View File

@@ -136,6 +136,13 @@ export enum ReaderScrollAmount {
LARGE = 95, LARGE = 95,
} }
export interface SafeAreaInset {
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
}
export interface IReaderSettingsGlobal { export interface IReaderSettingsGlobal {
overlayMode: ReaderOverlayMode; overlayMode: ReaderOverlayMode;
exitMode: ReaderExitMode; exitMode: ReaderExitMode;
@@ -169,6 +176,7 @@ export interface IReaderSettingsGlobal {
scrollAmount: ReaderScrollAmount; scrollAmount: ReaderScrollAmount;
shouldUseInfiniteScroll: boolean; shouldUseInfiniteScroll: boolean;
shouldShowTransitionPage: boolean; shouldShowTransitionPage: boolean;
safeAreaInset: SafeAreaInset;
} }
export interface IReaderSettingsManga { export interface IReaderSettingsManga {

View File

@@ -36,6 +36,7 @@ const BaseReaderPageNumber = ({
shouldShowPageNumber: state.shouldShowPageNumber, shouldShowPageNumber: state.shouldShowPageNumber,
progressBarType: state.progressBarType, progressBarType: state.progressBarType,
})); }));
const safeAreaInset = useReaderSettingsStore((state) => state.safeAreaInset);
const isMaximized = useReaderProgressBarStore('isMaximized'); const isMaximized = useReaderProgressBarStore('isMaximized');
const pageName = useMemo(() => { const pageName = useMemo(() => {
@@ -68,7 +69,8 @@ const BaseReaderPageNumber = ({
position: 'fixed', position: 'fixed',
left: readerNavBarWidth, left: readerNavBarWidth,
right: 0, right: 0,
bottom: (theme) => `calc(${theme.spacing(1)} + max(${scrollbar.xSize}px, env(safe-area-inset-bottom)))`, bottom: (theme) =>
`calc(${theme.spacing(1)} + max(${scrollbar.xSize}px, ${safeAreaInset.bottom ? 'env(safe-area-inset-bottom)' : '0px'}))`,
alignItems: 'center', alignItems: 'center',
transition: (theme) => `left 0.${theme.transitions.duration.shortest}s`, transition: (theme) => `left 0.${theme.transitions.duration.shortest}s`,
}} }}

View File

@@ -74,7 +74,9 @@ const BaseReader = ({
'tapZoneInvertMode', 'tapZoneInvertMode',
'shouldShowReadingModePreview', 'shouldShowReadingModePreview',
'shouldShowTapZoneLayoutPreview', 'shouldShowTapZoneLayoutPreview',
'safeAreaInset',
); );
const safeAreaInset = useReaderSettingsStore((state) => state.safeAreaInset);
const scrollElementRef = useRef<HTMLDivElement | null>(null); const scrollElementRef = useRef<HTMLDivElement | null>(null);
@@ -228,10 +230,10 @@ const BaseReader = ({
maxWidth: `calc(100vw - ${readerNavBarWidth}px)`, maxWidth: `calc(100vw - ${readerNavBarWidth}px)`,
width: `calc(100vw - ${readerNavBarWidth}px)`, width: `calc(100vw - ${readerNavBarWidth}px)`,
height: `100vh`, height: `100vh`,
pt: 'env(safe-area-inset-top)', pt: safeAreaInset.top ? 'env(safe-area-inset-top)' : undefined,
pb: 'env(safe-area-inset-bottom)', pb: safeAreaInset.bottom ? 'env(safe-area-inset-bottom)' : undefined,
pr: 'env(safe-area-inset-right)', pr: safeAreaInset.right ? 'env(safe-area-inset-right)' : undefined,
pl: 'env(safe-area-inset-left)', pl: safeAreaInset.left ? 'env(safe-area-inset-left)' : undefined,
marginLeft: `${readerNavBarWidth}px`, marginLeft: `${readerNavBarWidth}px`,
transition: (theme) => transition: (theme) =>
`width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s`, `width 0.${theme.transitions.duration.shortest}s, margin-left 0.${theme.transitions.duration.shortest}s`,

View File

@@ -155,6 +155,7 @@ const GLOBAL_READER_SETTING_OBJECT: Record<keyof IReaderSettingsGlobal, undefine
scrollAmount: undefined, scrollAmount: undefined,
shouldUseInfiniteScroll: undefined, shouldUseInfiniteScroll: undefined,
shouldShowTransitionPage: undefined, shouldShowTransitionPage: undefined,
safeAreaInset: undefined,
}; };
export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT); export const GLOBAL_READER_SETTING_KEYS = Object.keys(GLOBAL_READER_SETTING_OBJECT);
@@ -242,6 +243,12 @@ export const DEFAULT_READER_SETTINGS: IReaderSettings = {
scrollAmount: ReaderScrollAmount.LARGE, scrollAmount: ReaderScrollAmount.LARGE,
shouldUseInfiniteScroll: true, shouldUseInfiniteScroll: true,
shouldShowTransitionPage: true, shouldShowTransitionPage: true,
safeAreaInset: {
top: true,
right: true,
bottom: true,
left: true,
},
}; };
export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record<ProgressBarPosition, TooltipProps['placement']> = { export const READER_PROGRESS_BAR_POSITION_TO_PLACEMENT: Record<ProgressBarPosition, TooltipProps['placement']> = {

View File

@@ -84,6 +84,7 @@ const convertSettingsToMetadata = (
readerWidth: JSON.stringify(settings.readerWidth), readerWidth: JSON.stringify(settings.readerWidth),
hotkeys: JSON.stringify(settings.hotkeys), hotkeys: JSON.stringify(settings.hotkeys),
autoScroll: JSON.stringify(settings.autoScroll), autoScroll: JSON.stringify(settings.autoScroll),
safeAreaInset: JSON.stringify(settings.autoScroll),
}); });
export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDefaultFlag( export const DEFAULT_READER_SETTINGS_WITH_DEFAULT_FLAG = convertToSettingsWithDefaultFlag(

View File

@@ -16,6 +16,7 @@ import { ProgressBarType, ReaderOverlayMode } from '@/features/reader/Reader.typ
import { ReaderSettingOverlayMode } from '@/features/reader/overlay/settings/ReaderSettingOverlayMode.tsx'; import { ReaderSettingOverlayMode } from '@/features/reader/overlay/settings/ReaderSettingOverlayMode.tsx';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx'; import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import { ReaderSettingBackgroundColor } from '@/features/reader/settings/general/components/ReaderSettingBackgroundColor.tsx'; import { ReaderSettingBackgroundColor } from '@/features/reader/settings/general/components/ReaderSettingBackgroundColor.tsx';
import { ReaderSettingSafeAreaInset } from '@/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx';
export const ReaderGeneralSettings = ({ export const ReaderGeneralSettings = ({
overlayMode, overlayMode,
@@ -59,6 +60,10 @@ export const ReaderGeneralSettings = ({
onChange={(_, checked) => updateSetting('shouldShowPageNumber', checked)} onChange={(_, checked) => updateSetting('shouldShowPageNumber', checked)}
/> />
)} )}
<ReaderSettingSafeAreaInset
safeAreaInset={settings.safeAreaInset}
updateSetting={(value) => updateSetting('safeAreaInset', value)}
/>
</Stack> </Stack>
); );
}; };

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useLingui } from '@lingui/react/macro';
import { msg } from '@lingui/core/macro';
import type { ValueToDisplayData } from '@/base/Base.types.ts';
import { ButtonSelectInput } from '@/base/components/inputs/ButtonSelectInput.tsx';
import type { IReaderSettingsWithDefaultFlag, SafeAreaInset } from '@/features/reader/Reader.types.ts';
import { DEFAULT_READER_SETTINGS } from '@/features/reader/settings/ReaderSettings.constants.tsx';
const VALUE_TO_DISPLAY_DATA: ValueToDisplayData<keyof SafeAreaInset> = {
top: {
title: msg`Top`,
icon: null,
},
right: {
title: msg`Right`,
icon: null,
},
bottom: {
title: msg`Bottom`,
icon: null,
},
left: {
title: msg`Left`,
icon: null,
},
};
const READER_SAFE_AREA_INSET_VALUES = Object.keys(DEFAULT_READER_SETTINGS.safeAreaInset) as (keyof SafeAreaInset)[];
export const ReaderSettingSafeAreaInset = ({
safeAreaInset,
updateSetting,
}: Pick<IReaderSettingsWithDefaultFlag, 'safeAreaInset'> & {
updateSetting: (safeAreaInset: SafeAreaInset) => void;
}) => {
const { t } = useLingui();
return (
<ButtonSelectInput
label={t`Apply safe area padding`}
description={t`Prevents content from overlapping device cutouts and screen edges`}
value={Object.entries(safeAreaInset)
.filter(([, enabled]) => enabled)
.map(([key]) => key as keyof SafeAreaInset)}
values={READER_SAFE_AREA_INSET_VALUES}
setValue={(values) =>
updateSetting(
Object.fromEntries(
READER_SAFE_AREA_INSET_VALUES.map((key) => [key, values.includes(key)]),
) as unknown as SafeAreaInset,
)
}
valueToDisplayData={VALUE_TO_DISPLAY_DATA}
/>
);
};

View File

@@ -93,7 +93,7 @@ export const ReaderSettingTapZoneInvertMode = ({
const tapZonesInvertOption = convertTapZoneInvertModeToOption(tapZoneInvertMode.value); const tapZonesInvertOption = convertTapZoneInvertModeToOption(tapZoneInvertMode.value);
return ( return (
<ButtonSelectInput <ButtonSelectInput<TapZonesInvertOption>
{...buttonSelectInputProps} {...buttonSelectInputProps}
label={t`Invert tap zones`} label={t`Invert tap zones`}
value={tapZoneInvertMode.isDefault ? undefined : tapZonesInvertOption} value={tapZoneInvertMode.isDefault ? undefined : tapZonesInvertOption}

View File

@@ -414,6 +414,10 @@ msgstr "Also remove from {0}"
msgid "Appearance" msgid "Appearance"
msgstr "Appearance" msgstr "Appearance"
#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx
msgid "Apply safe area padding"
msgstr "Apply safe area padding"
#: src/features/migration/MigrationManager.ts #: src/features/migration/MigrationManager.ts
msgid "Are you sure you want to abort the migration?" msgid "Are you sure you want to abort the migration?"
msgstr "Are you sure you want to abort the migration?" msgstr "Are you sure you want to abort the migration?"
@@ -587,6 +591,7 @@ msgid "Both"
msgstr "Both" msgstr "Both"
#: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx #: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx
#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx
msgid "Bottom" msgid "Bottom"
msgstr "Bottom" msgstr "Bottom"
@@ -2052,6 +2057,7 @@ msgid "Layout"
msgstr "Layout" msgstr "Layout"
#: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx #: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx
#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx
msgid "Left" msgid "Left"
msgstr "Left" msgstr "Left"
@@ -2623,6 +2629,10 @@ msgstr "Preload images"
msgid "Press keys" msgid "Press keys"
msgstr "Press keys" msgstr "Press keys"
#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx
msgid "Prevents content from overlapping device cutouts and screen edges"
msgstr "Prevents content from overlapping device cutouts and screen edges"
#: src/features/settings/Settings.constants.ts #: src/features/settings/Settings.constants.ts
msgid "Preview" msgid "Preview"
msgstr "Preview" msgstr "Preview"
@@ -2830,6 +2840,7 @@ msgid "Retry errored pages"
msgstr "Retry errored pages" msgstr "Retry errored pages"
#: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx #: src/features/reader/overlay/progress-bar/settings/components/ReaderSettingProgressBarPosition.tsx
#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx
msgid "Right" msgid "Right"
msgstr "Right" msgstr "Right"
@@ -3508,6 +3519,10 @@ msgstr "Toggle offset double spreads"
msgid "Toggle stretch image" msgid "Toggle stretch image"
msgstr "Toggle stretch image" msgstr "Toggle stretch image"
#: src/features/reader/settings/general/components/ReaderSettingSafeAreaInset.tsx
msgid "Top"
msgstr "Top"
#: src/features/library/components/LibraryOptionsPanel.tsx #: src/features/library/components/LibraryOptionsPanel.tsx
msgid "Total chapters" msgid "Total chapters"
msgstr "Total chapters" msgstr "Total chapters"